(() => { const STORAGE_KEYS = { theme: 'altin.theme', token: 'altin.token', serverUrl: 'altin.serverUrl', }; const state = { serverUrl: '', token: '', agents: [], tasks: [], tasksUnavailable: false, }; const els = {}; const palette = [ '#d4af37', '#7aa2f7', '#8bd17c', '#e88f6a', '#c97ff5', '#55c0b7', '#f0c674', '#d46a6a' ]; // An agent that checked in more recently than this counts as active. Tombili's default // check-in interval is 10s with 5s of jitter, so 60s tolerates a few missed callbacks // while still flagging agents whose sleep interval has been raised via setsleep. const ACTIVE_WINDOW_MS = 300 * 1000; // Shown instead of a number when a metric's source endpoint failed, so a 0 is never // mistaken for "none exist". const UNAVAILABLE = '—'; const normalizeServer = (value) => value.trim().replace(/\/+$/, ''); const escapeHTML = (value) => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const setTheme = (theme) => { const nextTheme = theme === 'light' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', nextTheme); localStorage.setItem(STORAGE_KEYS.theme, nextTheme); if (els.themeToggle) { els.themeToggle.textContent = nextTheme === 'dark' ? 'Light' : 'Dark'; els.themeToggle.setAttribute('aria-label', `Switch to ${nextTheme === 'dark' ? 'light' : 'dark'} theme`); } }; const getTheme = () => localStorage.getItem(STORAGE_KEYS.theme) || 'dark'; // The header carries no progress or success text any more - only failures, which // is what used to get silently dropped when these elements were missing. The // element starts empty and .page-error:empty hides it, so a healthy load leaves // no residue under the page title. const setError = (message) => { if (!els.pageError) return; els.pageError.textContent = message; }; const redirectToLogin = () => { window.location.href = '/login.html'; }; const requireAuth = () => { const token = localStorage.getItem(STORAGE_KEYS.token); const serverUrl = localStorage.getItem(STORAGE_KEYS.serverUrl); if (!token || !serverUrl) { redirectToLogin(); return false; } state.token = token; state.serverUrl = normalizeServer(serverUrl); return true; }; const apiFetch = async (path, options = {}) => { const headers = new Headers(options.headers || {}); headers.set('Authorization', state.token); if (options.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } const response = await fetch(`${state.serverUrl}${path}`, { ...options, headers, }); if (response.status === 401) { redirectToLogin(); throw new Error('Unauthorized'); } return response; }; const loadJSON = async (path) => { const response = await apiFetch(path); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.message || `Request failed (${response.status}) for ${path}`); } return payload; }; const formatNumber = (value) => new Intl.NumberFormat().format(value || 0); // The server writes naive *local* timestamps (datetime.now()) but serialises them // with a GMT/UTC label, so parsing that label as UTC makes every timestamp look // hours in the past. Here that silently pinned every live agent to "Inactive". // // Rule: a real numeric offset (or Z) is authoritative and is trusted as-is. A bare // GMT/UTC label on an otherwise naive value is that bug, so the wall-clock fields // are re-read as local time instead. // // This is only exact when the browser and the server share a timezone, which holds // for single-host deployments. The real fix is datetime.now(timezone.utc) in // server/altin/agent.py. const parseServerTime = (value) => { if (!value) return NaN; if (typeof value === 'number') return value; if (/^\d{10,}$/.test(String(value).trim())) return Number(value); const text = String(value).trim(); if (/[+-]\d{2}:?\d{2}$/.test(text) || /Z$/i.test(text)) { return new Date(text).getTime(); } const iso = text.match(/^(?:[A-Za-z]{3},?\s*)?(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/); if (iso) { return new Date(+iso[1], +iso[2] - 1, +iso[3], +iso[4], +iso[5], +iso[6]).getTime(); } const rfc = text.match(/^[A-Za-z]{3},?\s*(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/); if (rfc) { const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const month = months.indexOf(rfc[2].toLowerCase()); if (month >= 0) { return new Date(+rfc[3], month, +rfc[1], +rfc[4], +rfc[5], +rfc[6]).getTime(); } } return new Date(text).getTime(); }; const formatTimeAgo = (value) => { const time = parseServerTime(value); if (!Number.isFinite(time)) return 'unknown'; const delta = Math.max(0, Date.now() - time); const minutes = Math.floor(delta / 60000); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d ago`; if (hours > 0) return `${hours}h ago`; if (minutes > 0) return `${minutes}m ago`; return 'just now'; }; const isAgentActive = (agent) => { const lastSeen = parseServerTime(agent.last_checkin); if (!Number.isFinite(lastSeen)) return false; return (Date.now() - lastSeen) < ACTIVE_WINDOW_MS; }; const buildCountMap = (items, keyFn) => { const map = new Map(); items.forEach((item) => { const key = keyFn(item) || 'unknown'; map.set(key, (map.get(key) || 0) + 1); }); return [...map.entries()].sort((a, b) => b[1] - a[1]); }; const renderMetrics = () => { const sent = state.tasks.filter((task) => task.sent).length; const completed = state.tasks.filter((task) => task.completed).length; const active = state.agents.filter(isAgentActive).length; const inactive = state.agents.length - active; // A 0 here would read as "no tasks exist" when the truth is "we could not ask". const taskValue = state.tasksUnavailable ? UNAVAILABLE : formatNumber(state.tasks.length); const taskNote = state.tasksUnavailable ? 'task endpoint returned an error' : null; els.metricAgents.textContent = formatNumber(state.agents.length); els.metricActive.textContent = formatNumber(active); els.metricInactive.textContent = formatNumber(inactive); els.metricTasks.textContent = taskValue; els.metricSent.textContent = state.tasksUnavailable ? UNAVAILABLE : formatNumber(sent); els.metricCompleted.textContent = state.tasksUnavailable ? UNAVAILABLE : formatNumber(completed); }; const drawPieChart = (canvas, data) => { const ctx = canvas.getContext('2d'); const dpr = Math.max(1, window.devicePixelRatio || 1); const size = Math.floor(canvas.clientWidth); canvas.width = size * dpr; canvas.height = size * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, size, size); const total = data.reduce((sum, item) => sum + item.value, 0); const center = size / 2; const radius = size * 0.36; let startAngle = -Math.PI / 2; ctx.save(); ctx.translate(center, center); if (!total) { ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.03)'; ctx.fill(); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.stroke(); ctx.restore(); return; } data.forEach((item, index) => { const angle = (item.value / total) * Math.PI * 2; ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, radius, startAngle, startAngle + angle); ctx.closePath(); ctx.fillStyle = palette[index % palette.length]; ctx.fill(); startAngle += angle; }); ctx.beginPath(); ctx.arc(0, 0, radius * 0.58, 0, Math.PI * 2); ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-panel'); ctx.fill(); ctx.restore(); }; const renderDonut = ({ canvas, legend, totalChip, centerTotal, subtitle, keyFn, caption, emptyLabel }) => { const counts = buildCountMap(state.agents, keyFn); const total = state.agents.length; const slices = counts.map(([name, value]) => ({ name, value })); totalChip.textContent = formatNumber(total); centerTotal.textContent = formatNumber(total); subtitle.textContent = total ? caption : 'no agents registered'; if (!slices.length) { legend.innerHTML = `