const PING_MAX = 30; let pingResults = []; let manifest = null; const logStreams = new Map(); let statusPollTimer = null; /** Fallback when manifest API fails and static file is an older v1 without widgets. */ const DEFAULT_WIDGETS = [ { id: 'authoring-queue', type: 'queue', title: 'Authoring builds', integration: 'authoring' }, { id: 'authoring-detail', type: 'authoringDetail', title: 'Authoring detail', integration: 'authoring' }, { id: 'watcher', type: 'badge', title: 'Lima watcher', integration: 'watcher' }, { id: 'system-resources', type: 'systemResources', title: 'System resources', integration: 'resources' }, { id: 'timesheet-summary', type: 'timesheet', title: 'Hours this week', integration: 'timesheet' }, { id: 'scripts-health', type: 'log', title: 'Script logs', integration: 'scripts', logKey: 'orgWebBuild' }, { id: 'calibre-sync', type: 'log', title: 'Calibre export', integration: 'scripts', logKey: 'calibreSync' }, { id: 'org-web-build', type: 'buildStatus', title: 'Website build', integration: 'builds', lastRunKey: 'org_web' }, ]; function manifestWidgets(data) { return Array.isArray(data?.widgets) && data.widgets.length > 0 ? data.widgets : DEFAULT_WIDGETS; } function apiFetch(url, options = {}) { return fetch(url, { cache: 'no-store', ...options }); } export async function initDashboard() { manifest = await loadManifest(); applyDashboardMeta(manifest); renderLinks(manifest.links); renderWidgets(manifestWidgets(manifest)); renderBuilds(manifest.builds); await refreshIntegrationWidgets(); startStatusPolling(manifest.integrations); updateClock(); setInterval(updateClock, 1000); setDailyMessage(); startCountdown(); setInterval(fetchHealth, 10000); fetchHealth(); } async function loadManifest() { try { const apiRes = await apiFetch('/api/zone/manifest'); if (apiRes.ok) { const data = await apiRes.json(); validateManifest(data); return data; } } catch { /* fallback to static file for local dev */ } const res = await apiFetch('data/manifest.json'); if (!res.ok) throw new Error('Failed to load manifest'); const data = await res.json(); validateManifest(data); return data; } function validateManifest(data) { const required = ['dashboard', 'links', 'builds', 'status']; for (const key of required) { if (!(key in data)) throw new Error(`manifest missing ${key}`); } const ids = new Set(); for (const link of data.links) { if (ids.has(link.id)) throw new Error(`duplicate link id ${link.id}`); ids.add(link.id); } const buildIds = new Set(); for (const build of data.builds) { if (buildIds.has(build.id)) throw new Error(`duplicate build id ${build.id}`); buildIds.add(build.id); } } function applyDashboardMeta({ dashboard }) { document.title = dashboard.title; document.querySelector('.topbar h1').textContent = dashboard.title; document.querySelector('.tagline').textContent = dashboard.tagline; } function renderLinks(links) { const nav = document.getElementById('quickLinks'); nav.innerHTML = ''; const order = ['platform', 'infra']; for (const group of order) { for (const link of links.filter(l => l.group === group)) { const a = document.createElement('a'); a.href = link.href; a.className = 'link-card'; a.dataset.linkId = link.id; if (link.external) { a.target = '_blank'; a.rel = 'noreferrer'; } const span = document.createElement('span'); span.textContent = link.label; a.appendChild(span); nav.appendChild(a); } } } function renderWidgets(widgets) { const grid = document.getElementById('widgetGrid'); if (!grid) return; grid.innerHTML = ''; for (const widget of widgets) { grid.appendChild(createWidgetCard(widget)); } } function createWidgetCard(widget) { const card = document.createElement('article'); card.className = 'widget-card'; card.dataset.widgetId = widget.id; card.dataset.integration = widget.integration || ''; card.innerHTML = `

${widget.title}

Loading…
`; return card; } async function refreshIntegrationWidgets() { try { const res = await apiFetch('/api/zone/status'); if (!res.ok) { setIntegrationPollMessage(`Status poll failed (${res.status})`); return false; } const status = await res.json(); updateWidgets(status); setIntegrationPollMessage(`Integrations updated: ${new Date().toLocaleTimeString()}`); return true; } catch (err) { setIntegrationPollMessage('Status poll offline'); console.warn('zone status poll failed', err); return false; } } function setIntegrationPollMessage(text) { const el = document.getElementById('lastUpdated'); if (el) el.textContent = text; } function startStatusPolling(integrations) { if (statusPollTimer) clearInterval(statusPollTimer); const pollValues = Object.values(integrations || {}).map(i => i.pollMs || 10000); const pollMs = pollValues.length > 0 ? Math.min(...pollValues, 10000) : 10000; statusPollTimer = setInterval(() => refreshIntegrationWidgets(), pollMs); } function widgetElements(widgetId) { const card = document.querySelector(`[data-widget-id="${widgetId}"]`); return { body: card?.querySelector('.widget-body'), badge: card?.querySelector('.widget-badge'), }; } function updateWidgets(status) { const widgets = manifestWidgets(manifest); for (const widget of widgets) { try { updateWidgetCard(widget, status); } catch (err) { const { body } = widgetElements(widget.id); if (body) body.textContent = 'Update error'; console.warn(`widget ${widget.id} update failed`, err); } } } function updateWidgetCard(widget, status) { const { body, badge } = widgetElements(widget.id); if (!body) return; const type = widget.type || widget.integration; switch (type) { case 'queue': { const a = status.authoring || {}; const running = a.running ? 'Running' : 'Idle'; setBadge(badge, running, a.running ? 'warn' : a.error ? 'error' : 'ok'); const current = a.current?.title || a.message || 'No builds yet'; const queued = a.queued ?? a.pendingCount ?? (a.pending?.length || 0); body.textContent = `${current} · queued: ${queued}`; break; } case 'authoringDetail': { const a = status.authoring || {}; const pending = a.pendingCount ?? (a.pending?.length || 0); setBadge(badge, String(pending), pending > 0 ? 'warn' : 'ok'); const failed = a.lastFailedTitle ? `Last failed: ${a.lastFailedTitle}` : 'No recent failures'; body.textContent = `Pending: ${pending} · ${failed}`; break; } case 'badge': { const w = status.watcher || {}; const active = w.ActiveState || w.active || 'unknown'; const isActive = active === 'active'; setBadge(badge, active, isActive ? 'ok' : 'warn'); body.textContent = w.SubState ? `SubState: ${w.SubState}` : (w.message || 'Watcher'); break; } case 'timesheet': { const t = status.timesheet || {}; setBadge(badge, t.available ? 'OK' : '—', t.available ? 'ok' : 'neutral'); body.textContent = t.available ? `Week ${t.weekStart}: ${t.hoursWorked}h worked (${t.delta >= 0 ? '+' : ''}${t.delta}h vs contracted)` : (t.message || 'No data'); break; } case 'systemResources': { const resources = status.systemResources || {}; const cpuPct = numberOrNull(resources.cpu?.usagePct); const memoryPct = numberOrNull(resources.memory?.usedPct); const storagePct = numberOrNull(resources.storage?.usedPct); const maxPct = Math.max(cpuPct ?? 0, memoryPct ?? 0, storagePct ?? 0); setBadge(badge, maxPct >= 90 ? 'High' : 'OK', maxPct >= 90 ? 'error' : maxPct >= 75 ? 'warn' : 'ok'); body.innerHTML = ` ${resourceRow('CPU', cpuPct, resources.cpu?.cores ? `${resources.cpu.cores} cores` : '')} ${resourceRow('RAM', memoryPct, formatBytes(resources.memory?.usedBytes, resources.memory?.totalBytes))} ${resourceRow('Disk', storagePct, formatBytes(resources.storage?.usedBytes, resources.storage?.totalBytes))}`; break; } case 'log': { const info = (status.scripts || {})[widget.logKey] || {}; const label = info.label || widget.logKey; const stale = info.stale; setBadge(badge, stale ? 'Stale' : (info.exists === false ? 'Missing' : 'OK'), stale || info.exists === false ? 'error' : 'ok'); const mtime = info.mtime ? new Date(info.mtime).toLocaleString() : 'never'; const line = typeof info.lastLine === 'string' ? info.lastLine.slice(0, 80) : ''; body.textContent = `${label}: ${mtime}${info.staleReason ? ` (${info.staleReason})` : ''}${line ? ` · ${line}` : ''}`; break; } case 'buildStatus': { const run = (status.lastRuns || {})[widget.lastRunKey] || {}; const exit = run.exitCode; const ok = exit === 0 || exit === '0'; setBadge(badge, run.running ? 'Running' : (exit === '' || exit == null ? '—' : `exit ${exit}`), run.running ? 'warn' : ok ? 'ok' : 'error'); const when = run.lastRun && run.lastRun !== 'never' ? new Date(run.lastRun).toLocaleString() : 'never'; body.textContent = `Last run: ${when}`; break; } default: body.textContent = `Unknown widget type: ${type}`; setBadge(badge, '?', 'neutral'); } } function setBadge(el, text, tone) { if (!el) return; el.textContent = text; el.className = `badge ${tone} widget-badge`; } function numberOrNull(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function formatPct(value) { return value == null ? '—' : `${Math.round(value)}%`; } function formatBytes(used, total) { const usedNum = Number(used); const totalNum = Number(total); if (!Number.isFinite(usedNum) || !Number.isFinite(totalNum) || totalNum <= 0) return ''; return `${bytesToGiB(usedNum)} / ${bytesToGiB(totalNum)}`; } function bytesToGiB(bytes) { return `${(bytes / 1073741824).toFixed(1)} GiB`; } function resourceRow(label, pct, detail) { const width = Math.max(0, Math.min(100, pct ?? 0)); return `
${label} ${formatPct(pct)}${detail ? ` · ${detail}` : ''}
`; } function safeDomId(value) { return String(value).replace(/[^a-zA-Z0-9_-]/g, '-'); } function buildDomIds(build) { if (build.id === 'org_web') { return { btnId: 'buildBtn', killId: 'killWebBtn', resultId: 'buildResult', logId: 'logOutput', lastRunId: 'webLastRunMeta', }; } if (build.id === 'emacs') { return { btnId: 'emacsBtn', killId: 'killEmacsBtn', resultId: 'emacsResult', logId: 'emacsLogs', lastRunId: 'emacsLastRunMeta', }; } const id = safeDomId(build.id); return { btnId: `${id}Btn`, killId: `${id}CancelBtn`, resultId: `${id}Result`, logId: `${id}Logs`, lastRunId: `${id}LastRunMeta`, }; } function renderBuilds(builds) { const grid = document.getElementById('buildGrid'); grid.innerHTML = ''; for (const build of builds) { grid.appendChild(createBuildCard(build)); } document.querySelectorAll('.toggleLogs').forEach(btn => { btn.addEventListener('click', () => { const target = document.getElementById(btn.dataset.target); target.classList.toggle('hidden'); btn.textContent = target.classList.contains('hidden') ? 'Show logs' : 'Hide logs'; }); }); } function createBuildCard(build) { const article = document.createElement('article'); article.className = 'panel run-card'; article.dataset.buildId = build.id; article.setAttribute('data-build-id', build.id); const { btnId, killId, resultId, logId, lastRunId } = buildDomIds(build); article.innerHTML = `

${build.label}

${build.badge}

Waiting for action

${build.label} log
`; article.querySelector(`#${btnId}`).addEventListener('click', () => runBuild(build, btnId, killId, resultId, logId)); article.querySelector(`#${killId}`).addEventListener('click', async () => { const ok = await showConfirm('Cancel Build', `Force-kill the running ${build.label.toLowerCase()}?`); if (ok) killEndpoint(build.cancel.path, build.label); }); return article; } function updateClock() { const now = new Date(); document.getElementById('clockDisplay').textContent = now.toTimeString().slice(0, 8); document.getElementById('dateDisplay').textContent = now.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); } function showToast(msg, type = 'info', duration = 3500) { const el = document.createElement('div'); el.className = `toast ${type}`; el.textContent = msg; document.getElementById('toast-container').appendChild(el); setTimeout(() => { el.style.transition = 'opacity 0.3s,transform 0.3s'; el.style.opacity = '0'; el.style.transform = 'translateX(120%)'; setTimeout(() => el.remove(), 300); }, duration); } function showConfirm(title, body) { return new Promise(resolve => { const overlay = document.getElementById('modalOverlay'); document.getElementById('modalTitle').textContent = title; document.getElementById('modalBody').textContent = body; overlay.classList.remove('hidden'); function cleanup(r) { overlay.classList.add('hidden'); document.getElementById('modalConfirm').removeEventListener('click', onY); document.getElementById('modalCancel').removeEventListener('click', onN); resolve(r); } const onY = () => cleanup(true); const onN = () => cleanup(false); document.getElementById('modalConfirm').addEventListener('click', onY); document.getElementById('modalCancel').addEventListener('click', onN); }); } function addPingResult(ok) { pingResults.push(ok); if (pingResults.length > PING_MAX) pingResults.shift(); const container = document.getElementById('pingHistory'); container.innerHTML = ''; pingResults.forEach(r => { const b = document.createElement('div'); b.className = 'ping-bar'; b.style.height = r ? '28px' : '10px'; b.style.background = r ? 'var(--green)' : 'var(--red)'; container.appendChild(b); }); const pct = Math.round((pingResults.filter(Boolean).length / pingResults.length) * 100); document.getElementById('uptimePct').textContent = `${pct}%`; document.getElementById('uptimeFill').style.width = `${pct}%`; } async function fetchUptime() { try { const res = await fetch(manifest.status.uptime); const data = await res.json(); const ms = data.uptimeMs; const d = Math.floor(ms / 86400000); const h = Math.floor((ms % 86400000) / 3600000); const m = Math.floor((ms % 3600000) / 60000); document.getElementById('uptimeDays').textContent = d; document.getElementById('uptimeHours').textContent = h; document.getElementById('uptimeMins').textContent = m; document.getElementById('uptimeStart').textContent = new Date(data.serverStart).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); document.getElementById('uptimeStat').textContent = `${d}d ${h}h ${m}m`; } catch { /* ignore */ } } async function fetchLastRuns() { try { const res = await fetch(manifest.status.lastRuns); const data = await res.json(); for (const build of manifest.builds) { renderLastRun(buildDomIds(build).lastRunId, data[build.lastRunKey]); updateBuildStatusFromLastRun(build, data[build.lastRunKey]); } } catch { /* ignore */ } } function updateBuildStatusFromLastRun(build, info) { if (!build.statusDotId || !info || info.lastRun === 'never') return; const dot = document.getElementById(build.statusDotId); const text = document.getElementById(build.statusTextId); if (!dot || !text) return; if (info.exitCode === 0 || (build.successExitCodes && build.successExitCodes.includes(info.exitCode))) { dot.className = 'dot green'; text.textContent = 'Idle'; } else if (info.exitCode !== 'never' && info.exitCode !== undefined) { dot.className = 'dot red'; text.textContent = 'Error'; } } function renderLastRun(elId, info) { const el = document.getElementById(elId); if (!el || !info) return; const t = info.lastRun && info.lastRun !== 'never' ? new Date(info.lastRun).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : 'Never'; const code = info.exitCode !== undefined && info.exitCode !== 'never' ? info.exitCode : null; const codeHtml = code !== null ? `exit ${code}` : ''; el.innerHTML = `Last run: ${t}${codeHtml}`; } async function fetchHealth() { try { await fetch(manifest.status.health); document.getElementById('health').textContent = 'Online'; document.getElementById('healthBadge').textContent = 'Online'; document.getElementById('healthBadge').className = 'badge ok'; document.getElementById('serverDot').className = 'dot green'; document.getElementById('globalStatus').className = 'global-ok'; document.getElementById('globalStatus').innerHTML = '
All systems running normally'; addPingResult(true); fetchUptime(); fetchLastRuns(); } catch { document.getElementById('health').textContent = 'Offline'; document.getElementById('healthBadge').textContent = 'Offline'; document.getElementById('healthBadge').className = 'badge error'; document.getElementById('serverDot').className = 'dot red'; document.getElementById('globalStatus').className = 'global-error'; document.getElementById('globalStatus').innerHTML = '
Attention required'; addPingResult(false); } document.getElementById('lastUpdated').textContent = `Last checked: ${new Date().toLocaleTimeString()}`; } function startLogStream(url, container, buildId) { closeStream(buildId); container.innerHTML = ''; const es = new EventSource(url); es.addEventListener('log', e => { const line = document.createElement('span'); line.className = 'log-line'; line.innerHTML = e.data; container.appendChild(line); container.scrollTop = container.scrollHeight; }); es.addEventListener('done', () => { es.close(); logStreams.delete(buildId); }); es.onerror = () => { es.close(); logStreams.delete(buildId); const line = document.createElement('span'); line.className = 'log-line ansi-dim'; line.textContent = '-- stream closed --'; container.appendChild(line); }; logStreams.set(buildId, es); } function closeStream(buildId) { const s = logStreams.get(buildId); if (s) s.close(); logStreams.delete(buildId); } async function killEndpoint(url, label) { try { const res = await fetch(url, { method: 'DELETE' }); if (res.ok) showToast(`${label} cancelled`, 'warn'); else showToast('Nothing to cancel', 'info'); } catch { showToast('Kill request failed', 'error'); } } async function runBuild(build, btnId, killId, resultId, logId) { const ok = await showConfirm(build.confirmTitle, build.confirmBody); if (!ok) return; const btn = document.getElementById(btnId); const killBtn = document.getElementById(killId); const res = document.getElementById(resultId); const logEl = document.getElementById(logId); const dot = document.getElementById(build.statusDotId); const statusText = document.getElementById(build.statusTextId); btn.disabled = true; killBtn.classList.add('visible'); res.textContent = `Running ${build.label.toLowerCase()}...`; res.className = 'status-text running'; if (dot) dot.className = 'dot yellow'; if (statusText) statusText.textContent = 'Running'; showToast(`${build.label} started`, 'info'); try { const r = await fetch(build.start.path, { method: build.start.method || 'POST' }); if (!r.ok) throw new Error(); closeStream(build.id); startLogStream(build.logs.path, logEl, build.id); pollStatus(build, btnId, killId, resultId); } catch { res.textContent = `Could not start ${build.label.toLowerCase()}`; res.className = 'status-text failed'; btn.disabled = false; killBtn.classList.remove('visible'); if (dot) dot.className = 'dot red'; showToast(`Failed to start ${build.label.toLowerCase()}`, 'error'); } } function pollStatus(build, btnId, killId, resultId) { const interval = setInterval(async () => { const r = await fetch(build.status.path); const s = await r.json(); if (!s.running) { clearInterval(interval); document.getElementById(btnId).disabled = false; document.getElementById(killId).classList.remove('visible'); const res = document.getElementById(resultId); const dot = document.getElementById(build.statusDotId); const statusText = document.getElementById(build.statusTextId); const ok = build.successExitCodes ? build.successExitCodes.includes(s.lastExitCode) : s.lastExitCode === 0; if (ok) { res.textContent = `${build.label} completed`; res.className = 'status-text success'; if (dot) dot.className = 'dot green'; if (statusText) statusText.textContent = 'Done'; showToast(`${build.label} complete`, 'success'); } else { res.textContent = `${build.label} finished with issue (code ${s.lastExitCode})`; res.className = 'status-text failed'; if (dot) dot.className = 'dot red'; if (statusText) statusText.textContent = 'Error'; showToast(`${build.label} finished with errors`, 'error'); } closeStream(build.id); fetchLastRuns(); } }, 2000); } const messages = ['You are my favourite person.']; function setDailyMessage() { const today = new Date().toDateString(); const index = today.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % messages.length; document.getElementById('dailyMessage').textContent = messages[index]; } function startCountdown() { const target = new Date('2026-08-01T00:00:00'); document.getElementById('countdownLabel').textContent = 'Wedding'; function update() { const diff = target - new Date(); if (diff <= 0) { document.getElementById('countdownDisplay').innerHTML = '

Nikah is now

'; return; } document.getElementById('cd-days').textContent = String(Math.floor(diff / 86400000)).padStart(3, '0'); document.getElementById('cd-hours').textContent = String(Math.floor((diff / 3600000) % 24)).padStart(2, '0'); document.getElementById('cd-mins').textContent = String(Math.floor((diff / 60000) % 60)).padStart(2, '0'); document.getElementById('cd-secs').textContent = String(Math.floor((diff / 1000) % 60)).padStart(2, '0'); } update(); setInterval(update, 1000); } initDashboard().catch(err => { console.error(err); document.getElementById('globalStatus').className = 'global-error'; document.getElementById('globalStatus').innerHTML = '
Failed to load dashboard manifest'; });