asset_browser/public/admin/admin.js
2026-03-27 09:18:39 -04:00

750 lines
30 KiB
JavaScript
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// ── State ──────────────────────────────────────────────────────────────────
let currentUser = null;
let editingUserId = null;
let users = [];
let companyFilter = '';
let roleFilter = new Set(); // empty = all roles
let statusFilter = null; // null | 'active' | 'inactive'
let groupBy = 'company'; // 'company' | 'role' | 'status' | null
let syncroCustomers = []; // [{ id, name }] loaded once from Syncro
// ── Init ───────────────────────────────────────────────────────────────────
(async function init() {
try {
const r = await fetch('/auth/me');
if (!r.ok) { location.replace('/login.html?next=' + encodeURIComponent(location.pathname)); return; }
const { user } = await r.json();
currentUser = user;
document.getElementById('user-name').textContent = user.name;
const roleBadge = document.getElementById('user-role');
roleBadge.textContent = user.role;
roleBadge.dataset.role = user.role;
// Show server section only for superduperadmin
if (user.role === 'superduperadmin') {
document.getElementById('section-server').style.display = '';
}
// Hide elevated role options for non-superduperadmin
if (user.role !== 'superduperadmin') {
document.querySelector('#f-role option[value="superduperadmin"]').remove();
document.querySelector('#f-role option[value="admin"]').remove();
}
// Load users and Syncro customers in parallel
await Promise.all([loadUsers(), loadSyncroCustomers()]);
bindEvents();
loadApiUsage();
setInterval(loadApiUsage, 10_000);
} catch (e) {
console.error(e);
}
})();
// ── API helpers ────────────────────────────────────────────────────────────
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(path, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
// ── Syncro customers ───────────────────────────────────────────────────────
async function loadSyncroCustomers() {
const hint = document.getElementById('f-customer-hint');
const listbox = document.getElementById('f-syncro-customer');
try {
let page = 1, all = [];
while (true) {
const r = await fetch(`/syncro-api/customers?per_page=100&page=${page}`);
const data = await r.json();
const batch = data.customers ?? [];
const totalPages = data.meta?.total_pages ?? 1;
all = all.concat(batch);
if (page >= totalPages) break;
page++;
}
syncroCustomers = all
.map(c => ({ id: c.id, name: c.business_name ?? c.name ?? `Customer ${c.id}` }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
populateCustomerListbox('');
hint.textContent = 'Links this user to a Syncro customer for access control.';
} catch (e) {
hint.textContent = 'Could not load Syncro customers.';
console.error('Syncro customer load failed:', e);
}
}
function populateCustomerListbox(filter) {
const listbox = document.getElementById('f-syncro-customer');
const current = listbox.value;
const q = filter.toLowerCase();
// Keep the "None" option then add matching customers
const matches = q
? syncroCustomers.filter(c => c.name.toLowerCase().includes(q))
: syncroCustomers;
listbox.innerHTML = '<option value="">— None (internal user) —</option>';
for (const c of matches) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
listbox.appendChild(opt);
}
// Restore selection if still present
if (current && listbox.querySelector(`option[value="${current}"]`)) {
listbox.value = current;
}
}
// ── Load & render users ────────────────────────────────────────────────────
async function loadUsers() {
try {
const { users: list } = await api('GET', '/admin/users');
users = list;
rebuildCompanyFilter();
renderUsers();
} catch (e) {
document.getElementById('user-tbody').innerHTML =
`<tr><td colspan="7" class="empty-state" style="color:var(--red);">Failed to load users: ${e.message}</td></tr>`;
}
}
function rebuildCompanyFilter() {
// Collect unique non-empty companies, sorted
const companies = [...new Set(
users.map(u => u.company).filter(Boolean)
)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
// Filter dropdown
const select = document.getElementById('company-filter');
const prev = select.value;
select.innerHTML = '<option value="">All Companies</option>';
for (const c of companies) {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
select.appendChild(opt);
}
select.value = companies.includes(prev) ? prev : '';
companyFilter = select.value;
}
function renderUsers() {
const tbody = document.getElementById('user-tbody');
const visible = users.filter(u => {
if (companyFilter && u.company !== companyFilter) return false;
if (roleFilter.size > 0 && !roleFilter.has(u.role)) return false;
if (statusFilter === 'active' && !u.active) return false;
if (statusFilter === 'inactive' && u.active) return false;
return true;
});
const hasFilter = companyFilter || roleFilter.size > 0 || statusFilter !== null;
if (!visible.length) {
tbody.innerHTML = `<tr><td colspan="7" class="empty-state">${hasFilter ? 'No users match the current filters.' : 'No users found.'}</td></tr>`;
return;
}
const rows = [];
if (groupBy) {
const keyOf = u => {
if (groupBy === 'company') return u.company || '';
if (groupBy === 'role') return u.role;
if (groupBy === 'status') return u.active ? 'active' : 'inactive';
return '';
};
const labelOf = key => {
if (groupBy === 'company') return key || 'No Company';
if (groupBy === 'role') return { client: 'Client', tech: 'Tech', admin: 'Admin', superduperadmin: 'Super Admin' }[key] ?? key;
if (groupBy === 'status') return key === 'active' ? 'Active' : 'Inactive';
return key;
};
const sortKeys = keys => {
if (groupBy === 'company') return keys.sort((a, b) => !a && b ? 1 : a && !b ? -1 : a.localeCompare(b, undefined, { sensitivity: 'base' }));
if (groupBy === 'role') return keys.sort((a, b) => (['superduperadmin','admin','tech','client'].indexOf(a) - ['superduperadmin','admin','tech','client'].indexOf(b)));
if (groupBy === 'status') return keys.sort((a, b) => a === 'active' ? -1 : 1);
return keys;
};
const grouped = new Map();
for (const u of visible) {
const key = keyOf(u);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(u);
}
for (const key of sortKeys([...grouped.keys()])) {
rows.push(`<tr class="group-row"><td colspan="7">${esc(labelOf(key))}</td></tr>`);
for (const u of grouped.get(key)) rows.push(userRow(u));
}
} else {
for (const u of visible) rows.push(userRow(u));
}
tbody.innerHTML = rows.join('');
}
function userRow(u) {
const activeClass = u.active ? 'active' : 'inactive';
const activeLabel = u.active ? 'Active' : 'Inactive';
const created = new Date(u.created_at + 'Z').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
const isSelf = u.id === currentUser.id;
const toggleBtn = u.active
? `<button class="btn btn-danger btn-sm" data-action="toggle" data-id="${u.id}" data-activate="false" ${isSelf ? 'disabled title="Cannot deactivate your own account"' : ''}>Deactivate</button>`
: `<button class="btn btn-ghost btn-sm" data-action="toggle" data-id="${u.id}" data-activate="true">Activate</button>`;
return `
<tr>
<td class="td-name">${esc(u.name)}${isSelf ? ' <span style="font-size:.72rem;color:var(--gray-400);font-weight:400;">(you)</span>' : ''}</td>
<td class="td-username">${esc(u.username)}</td>
<td style="color:var(--gray-600);font-size:.85rem;">${u.company ? esc(u.company) : '<span style="color:var(--gray-300);">—</span>'}</td>
<td><span class="role-badge" data-role="${esc(u.role)}">${esc(u.role)}</span></td>
<td><span class="status-badge ${activeClass}"><span class="status-dot"></span>${activeLabel}</span></td>
<td style="color:var(--gray-500);font-size:.82rem;">${created}</td>
<td class="td-actions">
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${u.id}">Edit</button>
${toggleBtn}
</td>
</tr>`;
}
// ── App menu ───────────────────────────────────────────────────────────────
function initAdminMenu() {
const toggle = document.getElementById('btn-menu-toggle');
const menu = document.getElementById('app-menu');
if (!toggle || !menu) return;
toggle.addEventListener('click', e => {
e.stopPropagation();
const opening = menu.hidden;
menu.hidden = !opening;
toggle.setAttribute('aria-expanded', String(opening));
});
document.addEventListener('click', e => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== toggle && !toggle.contains(e.target)) {
menu.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !menu.hidden) {
menu.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
}
});
}
// ── User filter panel ──────────────────────────────────────────────────────
function bindFilterPanel() {
const panel = document.getElementById('user-filter-panel');
const filterBtn = document.getElementById('btn-user-filter');
filterBtn?.addEventListener('click', () => {
panel.hidden = !panel.hidden;
filterBtn.classList.toggle('active', !panel.hidden);
});
panel?.addEventListener('click', e => {
const chip = e.target.closest('.af-chip');
if (!chip) return;
const group = chip.closest('[data-filter-group]')?.dataset.filterGroup;
if (group === 'role') {
const val = chip.dataset.value;
if (roleFilter.has(val)) { roleFilter.delete(val); chip.classList.remove('active'); }
else { roleFilter.add(val); chip.classList.add('active'); }
} else if (group === 'status') {
statusFilter = chip.dataset.value === '' ? null : chip.dataset.value;
chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c =>
c.classList.toggle('active', c.dataset.value === (chip.dataset.value))
);
} else if (group === 'groupby') {
groupBy = chip.dataset.value === '' ? null : chip.dataset.value;
chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c =>
c.classList.toggle('active', c.dataset.value === chip.dataset.value)
);
}
updateFilterBadge();
renderUsers();
});
document.getElementById('af-clear')?.addEventListener('click', () => {
roleFilter.clear();
statusFilter = null;
companyFilter = '';
document.getElementById('company-filter').value = '';
panel?.querySelectorAll('[data-filter-group="role"] .af-chip').forEach(c => c.classList.remove('active'));
panel?.querySelectorAll('[data-filter-group="status"] .af-chip').forEach(c =>
c.classList.toggle('active', c.dataset.value === '')
);
groupBy = 'company';
panel?.querySelectorAll('[data-filter-group="groupby"] .af-chip').forEach(c =>
c.classList.toggle('active', c.dataset.value === 'company')
);
updateFilterBadge();
renderUsers();
});
document.getElementById('company-filter')?.addEventListener('change', e => {
companyFilter = e.target.value;
updateFilterBadge();
renderUsers();
});
}
function updateFilterBadge() {
const count = (companyFilter ? 1 : 0) + roleFilter.size + (statusFilter !== null ? 1 : 0);
const badge = document.getElementById('user-filter-badge');
if (badge) { badge.textContent = count; badge.hidden = count === 0; }
}
// ── Bind events ────────────────────────────────────────────────────────────
function bindEvents() {
initAdminMenu();
bindFilterPanel();
document.getElementById('btn-new-user').addEventListener('click', openCreate);
document.getElementById('btn-logout').addEventListener('click', async () => {
await fetch('/auth/logout', { method: 'POST' });
location.replace('/login.html');
});
// Table actions — delegated so they work after innerHTML re-renders
document.getElementById('user-tbody').addEventListener('click', e => {
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const id = Number(btn.dataset.id);
if (btn.dataset.action === 'edit') {
openEdit(id);
} else if (btn.dataset.action === 'toggle') {
confirmToggleActive(id, btn.dataset.activate === 'true');
}
});
// Customer picker search
document.getElementById('f-customer-search').addEventListener('input', e => {
populateCustomerListbox(e.target.value);
});
// Modal close / cancel
document.getElementById('modal-close').addEventListener('click', closeUserModal);
document.getElementById('modal-cancel').addEventListener('click', closeUserModal);
document.getElementById('user-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeUserModal();
});
// Confirm modal
document.getElementById('confirm-close').addEventListener('click', closeConfirm);
document.getElementById('confirm-cancel').addEventListener('click', closeConfirm);
document.getElementById('confirm-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeConfirm();
});
document.getElementById('user-form').addEventListener('submit', handleUserFormSubmit);
const btnRestart = document.getElementById('btn-restart');
if (btnRestart) {
btnRestart.addEventListener('click', () => {
showConfirm(
'Restart Server',
'The server process will exit and PM2 will restart it automatically. This takes about 12 seconds. Proceed?',
'Restart',
'btn-accent',
doRestart
);
});
}
document.getElementById('btn-refresh-usage')?.addEventListener('click', loadApiUsage);
// History panel toggle
document.getElementById('btn-history-toggle')?.addEventListener('click', () => {
_historyPanelVisible = !_historyPanelVisible;
const panel = document.getElementById('usage-history-panel');
if (panel) panel.hidden = !_historyPanelVisible;
document.getElementById('btn-history-toggle')?.classList.toggle('active', _historyPanelVisible);
_setHistoryWindowDescription();
loadApiUsage(); // immediate refresh so bar + chart update
});
// Timeframe chips
document.querySelectorAll('.usage-tf-chip').forEach(chip => {
chip.addEventListener('click', () => {
_historyWindowMs = parseInt(chip.dataset.window, 10);
document.querySelectorAll('.usage-tf-chip').forEach(c =>
c.classList.toggle('active', c === chip)
);
_setHistoryWindowDescription();
loadApiUsage();
});
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeUserModal(); closeConfirm(); }
});
}
// ── User modal ─────────────────────────────────────────────────────────────
function openCreate() {
editingUserId = null;
document.getElementById('modal-title').textContent = 'New User';
document.getElementById('modal-submit').textContent = 'Create User';
document.getElementById('f-password-label').textContent = 'Password';
document.getElementById('f-password-hint').textContent = 'Minimum 12 characters.';
document.getElementById('f-password').required = true;
document.getElementById('user-form').reset();
document.getElementById('modal-error').style.display = 'none';
document.getElementById('f-username').disabled = false;
document.getElementById('f-role').disabled = false;
document.getElementById('f-customer-search').value = '';
populateCustomerListbox('');
document.getElementById('f-syncro-customer').value = '';
document.getElementById('user-modal').style.display = 'flex';
document.getElementById('f-name').focus();
}
function openEdit(id) {
const u = users.find(x => x.id === id);
if (!u) return;
editingUserId = id;
document.getElementById('modal-title').textContent = 'Edit User';
document.getElementById('modal-submit').textContent = 'Save Changes';
document.getElementById('f-password-label').textContent = 'New Password';
document.getElementById('f-password-hint').textContent = 'Leave blank to keep current password. Minimum 12 characters if changing.';
document.getElementById('f-password').required = false;
document.getElementById('modal-error').style.display = 'none';
const isSelf = u.id === currentUser.id;
document.getElementById('f-name').value = u.name;
document.getElementById('f-username').value = u.username;
document.getElementById('f-username').disabled = true;
document.getElementById('f-role').disabled = isSelf;
document.getElementById('f-password').value = '';
// Pre-select Syncro customer
document.getElementById('f-customer-search').value = '';
populateCustomerListbox('');
document.getElementById('f-syncro-customer').value = u.syncro_customer_id ?? '';
const roleSelect = document.getElementById('f-role');
if (!roleSelect.querySelector(`option[value="${u.role}"]`)) {
const opt = document.createElement('option');
opt.value = u.role;
opt.textContent = u.role;
opt.dataset.temp = '1';
roleSelect.insertBefore(opt, roleSelect.firstChild);
}
roleSelect.value = u.role;
document.getElementById('user-modal').style.display = 'flex';
document.getElementById('f-name').focus();
}
function closeUserModal() {
document.getElementById('user-modal').style.display = 'none';
document.getElementById('f-role').disabled = false;
document.querySelectorAll('#f-role option[data-temp]').forEach(o => o.remove());
}
async function handleUserFormSubmit(e) {
e.preventDefault();
const errEl = document.getElementById('modal-error');
const submit = document.getElementById('modal-submit');
errEl.style.display = 'none';
submit.disabled = true;
const name = document.getElementById('f-name').value.trim();
const username = document.getElementById('f-username').value.trim();
const role = document.getElementById('f-role').value;
const password = document.getElementById('f-password').value;
const customerSelect = document.getElementById('f-syncro-customer');
const syncroCustomerId = customerSelect.value ? Number(customerSelect.value) : null;
const company = syncroCustomerId
? (customerSelect.options[customerSelect.selectedIndex]?.text ?? '')
: '';
try {
if (editingUserId === null) {
await api('POST', '/admin/users', { name, username, company, syncro_customer_id: syncroCustomerId, role, password });
toast('User created successfully.', 'success');
} else {
const body = { name, company, syncro_customer_id: syncroCustomerId, role };
if (password) body.password = password;
await api('PATCH', `/admin/users/${editingUserId}`, body);
toast('User updated successfully.', 'success');
}
closeUserModal();
await loadUsers();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = '';
} finally {
submit.disabled = false;
}
}
// ── Toggle active ──────────────────────────────────────────────────────────
function confirmToggleActive(id, activate) {
const u = users.find(x => x.id === id);
if (!u) return;
const action = activate ? 'activate' : 'deactivate';
showConfirm(
`${activate ? 'Activate' : 'Deactivate'} User`,
`Are you sure you want to ${action} <strong>${esc(u.name)}</strong> (${esc(u.username)})?${!activate ? ' They will be unable to log in.' : ''}`,
activate ? 'Activate' : 'Deactivate',
activate ? 'btn-primary' : 'btn-danger',
async () => {
try {
await api('PATCH', `/admin/users/${id}`, { active: activate });
toast(`User ${activate ? 'activated' : 'deactivated'}.`, activate ? 'success' : 'info');
await loadUsers();
} catch (err) {
toast(err.message, 'error');
}
}
);
}
// ── Restart ────────────────────────────────────────────────────────────────
async function doRestart() {
const btn = document.getElementById('btn-restart');
btn.disabled = true;
btn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin .7s linear infinite">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/>
</svg>
Restarting…`;
try {
await api('POST', '/admin/restart');
} catch {
// Server closed before responding — expected
}
toast('Server is restarting… page will refresh in a few seconds.', 'info');
setTimeout(() => location.reload(), 4000);
}
// ── Confirm modal ──────────────────────────────────────────────────────────
function showConfirm(title, message, okLabel, okClass, callback) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-message').innerHTML = message;
const okBtn = document.getElementById('confirm-ok');
okBtn.textContent = okLabel;
okBtn.className = `btn btn-sm ${okClass}`;
okBtn.onclick = async () => { closeConfirm(); await callback(); };
document.getElementById('confirm-modal').style.display = 'flex';
}
function closeConfirm() {
document.getElementById('confirm-modal').style.display = 'none';
}
// ── Toast ──────────────────────────────────────────────────────────────────
function toast(msg, type = 'info') {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── API usage ──────────────────────────────────────────────────────────────
let _historyWindowMs = 3_600_000; // 1h — active only when panel is visible
let _historyPanelVisible = false;
async function loadApiUsage() {
const bar = document.getElementById('api-usage-bar');
const label = document.getElementById('api-usage-label');
const sub = document.getElementById('api-usage-sub');
if (!bar) return;
try {
// Always fetch the current 60s count + 7-day limit-hit tally
const { requests, limit, limitHits7d = 0 } = await api('GET', '/admin/syncro-usage');
// Update limit-hit badge on the History button
const badge = document.getElementById('history-badge');
if (badge) {
badge.textContent = limitHits7d > 999 ? '999+' : String(limitHits7d);
badge.hidden = limitHits7d === 0;
}
let displayReq, displayLimit, pct;
if (_historyPanelVisible) {
// Fetch per-minute history for the selected window
const { buckets } = await api('GET', `/admin/syncro-history?window=${_historyWindowMs}`);
const windowTotal = (buckets ?? []).reduce((s, b) => s + b.count, 0);
displayReq = windowTotal;
displayLimit = Math.round(_historyWindowMs / 60_000) * limit; // minutes × 180
pct = Math.min(100, (windowTotal / displayLimit) * 100);
drawUsageChart(buckets ?? []);
} else {
displayReq = requests;
displayLimit = limit;
pct = Math.min(100, (requests / limit) * 100);
}
bar.style.width = pct + '%';
bar.className = 'api-usage-bar' + (pct >= 80 ? ' crit' : pct >= 50 ? ' warn' : '');
label.textContent = displayReq === 0
? `— / ${displayLimit.toLocaleString()}`
: `${displayReq.toLocaleString()} / ${displayLimit.toLocaleString()}`;
const timeStr = new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
sub.textContent = `Updated ${timeStr} · auto-refreshes every 10s`;
} catch (e) {
if (sub) sub.textContent = 'Failed to load usage data.';
}
}
function _setHistoryWindowDescription() {
const el = document.getElementById('api-usage-desc');
if (!el) return;
if (!_historyPanelVisible) {
el.textContent = 'Rolling 60-second request window';
return;
}
const labels = { 3600000: '1-hour', 86400000: '24-hour', 604800000: '7-day' };
el.textContent = `Rolling ${labels[_historyWindowMs] ?? ''} request window`;
}
function drawUsageChart(buckets) {
const canvas = document.getElementById('usage-chart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Size the backing store to physical pixels for crispness
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.offsetWidth;
const displayH = canvas.offsetHeight;
if (displayW === 0 || displayH === 0) return;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
ctx.scale(dpr, dpr);
const W = displayW, H = displayH;
const now = Date.now();
// Aggregation block size
let blockMs;
if (_historyWindowMs <= 3_600_000) blockMs = 60_000; // 1h → 1-min blocks (60 pts)
else if (_historyWindowMs <= 86_400_000) blockMs = 10 * 60_000; // 24h → 10-min blocks (144 pts)
else blockMs = 60 * 60_000; // 7d → 1-hour blocks (168 pts)
// Build lookup map from raw per-minute buckets
const bucketMap = new Map(buckets.map(b => [b.ts, b.count]));
// Generate aggregated points (avg req/min in each block)
const windowStart = now - _historyWindowMs;
const firstBlockStart = Math.floor(windowStart / blockMs) * blockMs;
const lastBlockStart = Math.floor(now / blockMs) * blockMs;
const points = [];
for (let t = firstBlockStart; t <= lastBlockStart; t += blockMs) {
let total = 0, mins = 0;
for (let m = t; m < t + blockMs; m += 60_000) { total += bucketMap.get(m) ?? 0; mins++; }
points.push({ t, value: mins > 0 ? total / mins : 0 });
}
if (points.length === 0) return;
// Layout padding
const padL = 28, padR = 8, padT = 10, padB = 20;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
const maxY = 180;
const toX = i => padL + (points.length > 1 ? i / (points.length - 1) : 0.5) * chartW;
const toY = v => padT + chartH * (1 - Math.min(v, maxY) / maxY);
ctx.clearRect(0, 0, W, H);
// Grid lines + Y-axis labels
[0, 60, 120, 180].forEach(v => {
const y = toY(v);
ctx.strokeStyle = v === 0 ? '#d1d5db' : '#e5e7eb';
ctx.lineWidth = 1;
ctx.setLineDash(v === 0 ? [] : [3, 3]);
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + chartW, y); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#9ca3af';
ctx.font = '9px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(String(v), padL - 4, y);
});
// Filled area gradient
const gradient = ctx.createLinearGradient(0, padT, 0, padT + chartH);
gradient.addColorStop(0, 'rgba(43,84,153,.22)');
gradient.addColorStop(1, 'rgba(43,84,153,.02)');
ctx.beginPath();
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(toX(i), toY(p.value));
else ctx.lineTo(toX(i), toY(p.value));
});
ctx.lineTo(toX(points.length - 1), padT + chartH);
ctx.lineTo(toX(0), padT + chartH);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Line
ctx.beginPath();
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(toX(i), toY(p.value));
else ctx.lineTo(toX(i), toY(p.value));
});
ctx.strokeStyle = '#2b5499';
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.stroke();
// X-axis labels (5 evenly spaced)
const labelCount = Math.min(5, points.length);
ctx.fillStyle = '#9ca3af';
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
for (let li = 0; li < labelCount; li++) {
const idx = Math.round(li * (points.length - 1) / (labelCount - 1));
const d = new Date(points[idx].t);
const label = blockMs >= 60 * 60_000
? d.toLocaleDateString([], { month: 'short', day: 'numeric' })
: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
ctx.fillText(label, toX(idx), H - 3);
}
}
// ── Utility ───────────────────────────────────────────────────────────────
function esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}