From cf313f7abf96d8deee82c0324c8d5909d6bb60e9 Mon Sep 17 00:00:00 2001
From: marvin
-
+
Why Site Gateway · diff --git a/ROADMAP.md b/ROADMAP.md index d4c30d1..798df58 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -202,3 +202,6 @@ Roughly in priority order: `v0.16.37` combines the documentation catch-up for the hero panel arc (v0.16.29-36: README feature bullets and the environment-variable table, plus new "Live resource panel" and "Version" sections in the in-app manual's Administration System article, and an updated Dashboard "Runtime & System" article) with a real fix found while reviewing the Dashboard's hero panel live: the Throughput chip next to it (requests/min) was still only updating on the old 30-second `refreshDashboard()` timer, not the hero's 7-second poll, even though `/api/system/health` already computes that exact number on every call and the Dashboard was just discarding it. `refreshDashboardHero()` now also updates the Throughput chip from that same response, so it refreshes on the same cadence as the rest of the hero instead of lagging behind it by up to 23 seconds. `v0.16.38` fixes the Dashboard hero's Uptime tile reliably showing "0m" right after a page load or refresh, before slowly counting up from there rather than showing the real elapsed time immediately. Root cause: a 1-second ticker (`setInterval(() => updateDashboardUptime(), 1000)`) has always run independently of the real data fetch, calling `updateDashboardUptime()` with no argument once a second while the Dashboard is visible. The function's old anchor logic (`window.__dashboardStartedAt || (window.__dashboardStartedAt = ...)`) treated a bare, argument-less call as "anchor starts now" (zero elapsed), and because that anchor was set-once, a later call carrying the real `uptimeSeconds` from `/api/dashboard` was then a no-op -- the wrong zero-based anchor had already won the race, almost every time, since the ticker fires every second and the dashboard fetch takes at least one network round trip. `updateDashboardUptime()` now only ever sets the anchor from a real, finite `seconds` value, and does so every time real data arrives rather than once -- so it can't be raced by the bare ticker call (which now just re-renders using whatever anchor already exists, or does nothing until one does), and it also self-corrects if the container genuinely restarts while the page stays open, instead of drifting forever from a stale first anchor. Separately, confirmed by inspection (not a bug, but worth documenting): the Administration System tab's Version panel does *not* tick client-side the way the Dashboard's hero does -- its Uptime is a static string recomputed only when the page's shared dashboard data refetches (on load, or every 30 seconds while the Dashboard view specifically is the active one), so it can go visibly stale while sitting on the Administration tab. Left as-is for now since it's presented as build/version metadata rather than a live stat, but flagged in case a ticking version is wanted there too. + + +`v0.16.39` retires the Dashboard Uptime tile's separate 1-second client-side ticker, the same mechanism behind the v0.16.38 "resets to 0m on page load" bug, in favor of treating Uptime as just another field on the shared 7-second `/api/system/health` poll that already drives CPU, memory, swap, disk, and network on both the Dashboard and the Administration > System tab's hero panel. Revisiting the ticker after fixing its race condition, it turned out to be solving a precision problem the display doesn't actually have: `formatDuration()` only ever renders minute-level granularity ("2h 59m"), never seconds, so a per-second tick never changed what was on screen between one 7-second poll and the next. `systemHealthSnapshot()` now includes `uptimeSeconds` (from `process.uptime()`, the same source `dashboardSnapshot()` already used), and `renderHeroPanel()`'s former `includeThroughput` boolean became a `sixthSlot` option ("throughput" for the System tab, which has no other requests/min display, or "uptime" for the Dashboard, which already shows Throughput in its own chip) so the sixth hero slot can be either stat without a special case. The client ticker, its anchor state, and the whole race-condition class it enabled are gone: one poll, one code path, six stats, no anchor to get out of sync. diff --git a/package.json b/package.json index 25cfe5e..9ef6e4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.16.38", + "version": "0.16.39", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/app.js b/src/public/app.js index 7889886..c6bae82 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -147,7 +147,6 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") { function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `
Operations
No recent activity.
'; } -setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000); // --- Card rendering helpers (icons, permissions) ----------------------------------- @@ -550,17 +547,20 @@ async function refreshDashboard() { try { state.dashboard = await api("/api/dashboard"); renderDashboard(); } finally { button.disabled = false; button.classList.remove("spinning"); } } -// Populates the Dashboard's hero panel (CPU/memory/swap/disk/network -- Throughput is skipped -// here since the Dashboard already has its own live-requests chip, and Uptime is handled by the -// existing updateDashboardUptime() ticker rather than this endpoint) directly from +// Populates the Dashboard's hero panel (CPU/memory/swap/disk/network/uptime) directly from // /api/system/health, the same call and the same renderHeroPanel() the Administration > System // tab's hero uses, so the two can never show different numbers for the same live stat again. +// Uptime uses sixthSlot: "uptime" here (the System tab uses the default "throughput" slot instead, +// since the Dashboard already has its own live-requests chip elsewhere -- see below). There's no +// separate ticker or anchor for Uptime anymore: formatDuration() only ever shows minute-level +// granularity, so refreshing it on this same 7s poll as everything else is all the precision the +// display needs, and it removes a whole class of ticker/anchor race-condition bugs for free. async function refreshDashboardHero() { try { const health = await api("/api/system/health"); - window.renderHeroPanel?.("dashboard-hero", health, { includeThroughput: false }); + window.renderHeroPanel?.("dashboard-hero", health, { sixthSlot: "uptime" }); // /api/system/health already computes throughput.liveRequests (the hero just doesn't display - // it here, since the Dashboard shows it in its own chip instead -- see includeThroughput above). + // it here, since the Dashboard shows it in its own chip instead -- see sixthSlot above). // Reuse that number to keep the chip on the same 7s cadence as the hero, instead of leaving it // on the separate 30s refreshDashboard() timer, which was the actual bug being reported here. const throughputTotal = $("#dash-throughput-total"); if (throughputTotal && health.throughput) throughputTotal.textContent = health.throughput.liveRequests ?? 0; diff --git a/src/public/features.js b/src/public/features.js index ddf9277..5cc20e3 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -595,8 +595,12 @@ function setHeroStat(prefix, key, { value, percent, detail, tone } = {}) { // isn't available (e.g. no cgroup v2, no readable network interfaces, swap disabled on the host). // Throughput is System-tab-only -- the Dashboard already shows live requests/min in its own chip, // so `includeThroughput: false` there skips it rather than showing the same number twice. -function renderHeroPanel(prefix, health, { includeThroughput } = { includeThroughput: true }) { - const keys = includeThroughput ? ["cpu", "memory", "swap", "disk", "network", "throughput"] : ["cpu", "memory", "swap", "disk", "network"]; +function renderHeroPanel(prefix, health, { sixthSlot = "throughput" } = {}) { + // sixthSlot picks what the panel's sixth stat is: "throughput" (Administration > System, since + // that page has no other requests/min display) or "uptime" (the Dashboard, which already has + // its own Throughput chip elsewhere -- showing it twice added nothing). Both come straight off + // the same /api/system/health poll as everything else here, no separate ticker or anchor. + const keys = ["cpu", "memory", "swap", "disk", "network", sixthSlot]; if (!document.querySelector(`#${prefix}-${keys[0]}-value`)) return; if (!health) { keys.forEach(key => setHeroStat(prefix, key, { value: "\u2014", detail: "Unavailable" })); return; } const tone = percent => percent >= 90 ? "critical" : percent >= 75 ? "warning" : ""; @@ -622,7 +626,8 @@ function renderHeroPanel(prefix, health, { includeThroughput } = { includeThroug else setHeroStat(prefix, "disk", { value: "\u2014", detail: "Disk stats unavailable" }); if (health.network) setHeroStat(prefix, "network", { value: formatRate(health.network.rxBytesPerSec + health.network.txBytesPerSec), percent: 0, detail: `\u2193 ${formatRate(health.network.rxBytesPerSec)} \u00b7 \u2191 ${formatRate(health.network.txBytesPerSec)}` }); else setHeroStat(prefix, "network", { value: "\u2014", detail: "Sampling\u2026" }); - if (includeThroughput) setHeroStat(prefix, "throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" }); + if (sixthSlot === "throughput") setHeroStat(prefix, "throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" }); + else if (sixthSlot === "uptime") setHeroStat(prefix, "uptime", Number.isFinite(health.uptimeSeconds) ? { value: formatDuration(health.uptimeSeconds), detail: "Since last restart" } : { value: "\u2014", detail: "Unavailable" }); } // --- System tab: environment/integration status, storage, scheduled jobs, sync, restart -------- function renderSystemPanel() { @@ -686,7 +691,7 @@ function renderSystemPanel() { if (!state.systemHealthTimer) state.systemHealthTimer = setInterval(() => { const systemPanel = document.querySelector('[data-admin-panel="system"]'); if (state.view !== "administration" || !systemPanel || systemPanel.classList.contains("hidden")) return; - api("/api/system/health").then(health => renderHeroPanel("system-hero", health, { includeThroughput: true })).catch(() => {}); + api("/api/system/health").then(health => renderHeroPanel("system-hero", health)).catch(() => {}); }, 7000); } renderSystemStatus(panel); @@ -728,7 +733,7 @@ async function renderSystemStatus(panel) { api("/api/system/restart-policy"), api("/api/system/health").catch(() => null), ]); - renderHeroPanel("system-hero", health, { includeThroughput: true }); + renderHeroPanel("system-hero", health); if (security) security.innerHTML = [ { ok: !sec.adminPasswordIsDefault, label: "ADMIN_PASSWORD", detail: sec.adminPasswordIsDefault ? "Still using the built-in default \u2014 set this before exposing the dashboard." : "Configured." }, { ok: !sec.sessionSecretIsDefault, label: "SESSION_SECRET", detail: sec.sessionSecretIsDefault ? "Not set \u2014 sessions are keyed off the admin credentials instead of an independent secret." : "Configured." }, diff --git a/src/public/index.html b/src/public/index.html index 4e66b90..446300b 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -8,7 +8,7 @@