diff --git a/README.md b/README.md
index 3582f92..4c70cdf 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
Why Site Gateway · diff --git a/ROADMAP.md b/ROADMAP.md index 0d36c9b..c5cc20f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -194,3 +194,5 @@ Roughly in priority order: `v0.16.33` fixes the System tab hero panel's Network stat printing absurd, layout-breaking values like "846.7603211009175 B/s" instead of a clean rounded number. Root cause: `formatBytes()` only rounds once a value crosses into KB -- below 1024 it returns the number exactly as given, which has always been fine because every other caller passes it a file size (always a whole integer). The Network stat is the first caller to feed it a computed rate (bytes divided by elapsed seconds), which is almost never a whole number, so sub-1 KB/s readings rendered with a dozen decimal places and wrapped onto a second line, breaking the hero panel's layout. `formatRate()` now rounds to the nearest whole byte before handing off to `formatBytes()`, matching what every other value passing through it already looks like. `v0.16.34` fixes the System tab hero panel's helper text not lining up across columns -- Network and Throughput don't have a meaningful usage bar (neither is a percentage of anything), so that bar was hidden with `display:none`, which removes it from the flex layout entirely rather than just hiding it. The other four columns (CPU, Memory, Swap, Disk) still have their bar taking up space between the value and the detail line, so Network and Throughput's detail text sat visibly higher than everyone else's, breaking the row's shared baseline. Switched to `visibility:hidden`, which keeps the bar's space reserved without drawing it, so all six columns now keep identical vertical rhythm and every detail line lands on the same line. + +`v0.16.35` unifies the Dashboard's Runtime/System panel with the Administration > System tab's hero panel instead of the two showing different, disagreeing numbers for the same underlying stats. The Dashboard's Memory tile used to read `process.memoryUsage().rss` -- the Node process's own footprint, not the container's real usage -- while the System tab's hero (added in v0.16.29-34) correctly read cgroup v2's `memory.current`. There was no CPU stat on the Dashboard at all, and "Site Gateway data" plus "Storage available" were two separate numbers where the System tab's Disk stat already combined them into one coherent, `DATA_DIR_LIMIT_GB`-aware percentage. Rather than keep two implementations in sync by hand, the Dashboard's panel is now the exact same hero component -- same markup builder, same `renderHeroPanel()` function, same `/api/system/health` endpoint, same polling-while-visible pattern -- so the two can't disagree again, because there's only one implementation computing the numbers. The Dashboard's copy shows CPU, Memory, Swap, Disk, Network, and Uptime; Throughput is left out there since the Dashboard already has its own "requests / min" chip in its metric strip and showing the same number twice added nothing. Uptime keeps ticking client-side exactly as it did before (same `updateDashboardUptime()` timer, just now living inside the hero's sixth slot instead of a standalone tile). Everything else that used to live in that panel -- Site Gateway version, Caddy version, Database status, and Public IP -- moved to the Administration > System tab's existing Version panel, alongside the Site Gateway version and access-URL details already shown there, so nothing was lost, it just now lives with the rest of the deployment's operational metadata instead of being split across two pages. The Dashboard's hero grid also gets its own CSS breakpoint (3 columns by default, 2 below 900px) rather than reusing the System tab's viewport-keyed breakpoints, since it sits inside the Dashboard's half-width two-column layout rather than a full-width panel and would otherwise stay cramped at 6 columns on an ordinary desktop window. diff --git a/package.json b/package.json index ab5ac2a..2d96f9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.16.34", + "version": "0.16.35", "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 abf54c5..cdc2633 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -215,16 +215,10 @@ function renderDashboard() { $("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`; $("#health-checked").innerHTML = `Last checked ${formatTime(data.checkedAt)}`; updateDashboardUptime(data.system.uptimeSeconds); - $("#system-memory").textContent = formatBytes(data.system.memoryBytes); - $("#system-data").textContent = formatBytes(data.system.dataBytes); - $("#system-disk").textContent = formatBytes(data.system.diskFreeBytes); - $("#system-disk").title = `${formatBytes(data.system.diskFreeBytes)} available of ${formatBytes(data.system.diskTotalBytes)} on the /data volume`; - $("#system-app-version").textContent = `v${data.system.appVersion}`; - $("#system-caddy-version").textContent = data.system.caddyVersion; - $("#system-database").textContent = `${data.system.databaseEngine} · ${data.system.databaseStatus}`; - $("#system-database-detail").textContent = `${formatBytes(data.system.databaseBytes)} configuration database`; - $("#system-public-ip").textContent = data.system.publicIp || (data.system.publicIpError ? "Unavailable" : "Checking…"); - $("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked"; + // Memory/Data/Storage/Version/Database/Public IP moved to the Administration > System tab's + // Version panel -- the Dashboard's own Runtime/System panel is now the shared hero component + // (see renderHeroPanel/refreshDashboardHero), which reads real container-scoped CPU/memory/ + // swap/disk/network from /api/system/health instead of this endpoint's coarser numbers. $("#attention-panel").classList.toggle("is-clear", data.attention.length === 0); $("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0); $("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => item.kind === "drift" @@ -556,6 +550,15 @@ 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 +// /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. +async function refreshDashboardHero() { + try { const health = await api("/api/system/health"); window.renderHeroPanel?.("dashboard-hero", health, { includeThroughput: false }); } + catch { /* Hero keeps its last-known values if a poll fails -- same behavior as the System tab's own hero. */ } +} // --- Boot: session check, initial routing, periodic health/update checks ------------------- function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); } @@ -573,6 +576,11 @@ async function boot() { $("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView(); if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message, "error")); }, 30000); if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000); + // Dashboard hero panel: one immediate load so it isn't sitting on dashes until the first + // 7-second tick, then the same lightweight poll-while-visible pattern as the System tab's + // hero uses, gated on the Dashboard actually being the visible view. + if (state.view === "overview" && canAdmin()) refreshDashboardHero().catch(() => {}); + if (!state.dashboardHeroTimer) state.dashboardHeroTimer = setInterval(() => { if (state.view === "overview" && canAdmin() && !$("#dashboard").classList.contains("hidden")) refreshDashboardHero().catch(() => {}); }, 7000); } async function checkForUpdate() { diff --git a/src/public/features.js b/src/public/features.js index dd45364..ddf9277 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -576,42 +576,53 @@ document.addEventListener("click", async event => { // number verbatim, which is fine for the file sizes it’s normally fed (always whole integers) // but not for a computed rate, so round to a whole byte first. function formatRate(bytesPerSecond) { return `${formatBytes(Math.round(bytesPerSecond))}/s`; } -function setHeroStat(key, { value, percent, detail, tone } = {}) { - const valueEl = document.querySelector(`#system-hero-${key}-value`), fillEl = document.querySelector(`#system-hero-${key}-fill`), detailEl = document.querySelector(`#system-hero-${key}-detail`); +// Builds one hero panel's stat markup for a given prefix ("system-hero" on the Administration > +// System tab, "dashboard-hero" on the Dashboard) so both panels share one template instead of +// two hand-written copies that can drift apart. `slots` is the ordered list of stat keys/labels +// for that panel -- the two panels show a different sixth stat (Throughput vs. Uptime), since the +// Dashboard already has its own Throughput chip elsewhere and showing it twice would be redundant. +function heroSlotsMarkup(prefix, slots) { + return slots.map(([key, label]) => `
What\u2019s configured, what\u2019s running, and what this deployment can do. Nothing here is customizable except the Docker toggle below and the action buttons \u2014 everything else is status.
Environment
Environment
Operations
${extendedEscape(location.origin)}${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")} · Site ports: ${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}`;
+ if (version) {
+ // Uptime, Caddy version, Database status, and Public IP used to live on the Dashboard's
+ // Runtime/System panel -- that panel is now the shared hero component (CPU/memory/swap/disk/
+ // network), so this operational metadata moved here instead, reusing the same system.* fields
+ // from the global dashboard snapshot rather than a separate fetch.
+ const sys = state.dashboard?.system || {};
+ const uptime = Number.isFinite(sys.uptimeSeconds) ? formatDuration(sys.uptimeSeconds) : "Unavailable";
+ const database = sys.databaseEngine ? `${extendedEscape(sys.databaseEngine)} \u00b7 ${extendedEscape(sys.databaseStatus || "unknown")} \u00b7 ${formatBytes(sys.databaseBytes)}` : "Unavailable";
+ const publicIp = sys.publicIp || (sys.publicIpError ? "Unavailable" : "Checking\u2026");
+ const publicIpDetail = sys.publicIpError ? `check failed \u00b7 ${extendedEscape(sys.publicIpError)}` : sys.publicIpCheckedAt ? `checked ${extendedEscape(formatTime(sys.publicIpCheckedAt))}` : "not yet checked";
+ version.innerHTML = `Site Gateway v${extendedEscape(state.config?.version || "unknown")} \u00b7 Caddy ${extendedEscape(sys.caddyVersion || "unknown")}${extendedEscape(location.origin)}${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")} · Site ports: ${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}`;
+ }
try {
const [sec, store, policy, health] = await Promise.all([
api("/api/system/security"),
@@ -710,7 +728,7 @@ async function renderSystemStatus(panel) {
api("/api/system/restart-policy"),
api("/api/system/health").catch(() => null),
]);
- renderSystemHealthHero(health);
+ renderHeroPanel("system-hero", health, { includeThroughput: true });
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 438bee7..8054b68 100644
--- a/src/public/index.html
+++ b/src/public/index.html
@@ -8,7 +8,7 @@