Compare commits

..

2 Commits

7 changed files with 88 additions and 54 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white"> <img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2"> <img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0"> <img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.33-62E6A7"> <img alt="Version" src="https://img.shields.io/badge/version-0.16.35-62E6A7">
</p> </p>
<p> <p>
<a href="#why-site-gateway">Why Site Gateway</a> · <a href="#why-site-gateway">Why Site Gateway</a> ·
+4
View File
@@ -192,3 +192,7 @@ Roughly in priority order:
`v0.16.32` fixes a real bug in v0.16.31's `DATA_DIR_LIMIT_GB` disk allowance: the percentage it computed compared an assigned per-app allowance (e.g. 30 GB) against `statfs`'s used-space figure for the *entire filesystem* behind `/data` -- which on a shared array, cache pool, or any volume with other things living on it, has nothing to do with how much Site Gateway itself has actually written. A container assigned 30 GB sitting on a host volume that's 160 GB full of unrelated data showed as "534% used," which is a meaningless number dressed up as a warning. When `DATA_DIR_LIMIT_GB` is set, the Disk stat now compares against Site Gateway's own actual footprint instead -- the same recursive `/data` walk (`directorySize()`) the System tab's storage breakdown already performs -- so the percentage reflects what this app has actually written, not what else happens to share its disk. That walk only runs when the environment variable is actually set, since it isn't free and the whole-volume `statfs` numbers (used with no assigned limit configured) don't need it. `v0.16.32` fixes a real bug in v0.16.31's `DATA_DIR_LIMIT_GB` disk allowance: the percentage it computed compared an assigned per-app allowance (e.g. 30 GB) against `statfs`'s used-space figure for the *entire filesystem* behind `/data` -- which on a shared array, cache pool, or any volume with other things living on it, has nothing to do with how much Site Gateway itself has actually written. A container assigned 30 GB sitting on a host volume that's 160 GB full of unrelated data showed as "534% used," which is a meaningless number dressed up as a warning. When `DATA_DIR_LIMIT_GB` is set, the Disk stat now compares against Site Gateway's own actual footprint instead -- the same recursive `/data` walk (`directorySize()`) the System tab's storage breakdown already performs -- so the percentage reflects what this app has actually written, not what else happens to share its disk. That walk only runs when the environment variable is actually set, since it isn't free and the whole-volume `statfs` numbers (used with no assigned limit configured) don't need it.
`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.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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "site-gateway", "name": "site-gateway",
"version": "0.16.33", "version": "0.16.35",
"private": true, "private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module", "type": "module",
+18 -10
View File
@@ -215,16 +215,10 @@ function renderDashboard() {
$("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`; $("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`;
$("#health-checked").innerHTML = `<span class="live-dot" id="health-live-dot"></span>Last checked ${formatTime(data.checkedAt)}`; $("#health-checked").innerHTML = `<span class="live-dot" id="health-live-dot"></span>Last checked ${formatTime(data.checkedAt)}`;
updateDashboardUptime(data.system.uptimeSeconds); updateDashboardUptime(data.system.uptimeSeconds);
$("#system-memory").textContent = formatBytes(data.system.memoryBytes); // Memory/Data/Storage/Version/Database/Public IP moved to the Administration > System tab's
$("#system-data").textContent = formatBytes(data.system.dataBytes); // Version panel -- the Dashboard's own Runtime/System panel is now the shared hero component
$("#system-disk").textContent = formatBytes(data.system.diskFreeBytes); // (see renderHeroPanel/refreshDashboardHero), which reads real container-scoped CPU/memory/
$("#system-disk").title = `${formatBytes(data.system.diskFreeBytes)} available of ${formatBytes(data.system.diskTotalBytes)} on the /data volume`; // swap/disk/network from /api/system/health instead of this endpoint's coarser numbers.
$("#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";
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0); $("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
$("#dashboard-lower-columns").classList.toggle("attention-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" $("#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(); } try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
finally { button.disabled = false; button.classList.remove("spinning"); } 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 ------------------- // --- Boot: session check, initial routing, periodic health/update checks -------------------
function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); } 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(); $("#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.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); 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() { async function checkForUpdate() {
+47 -29
View File
@@ -576,42 +576,53 @@ document.addEventListener("click", async event => {
// number verbatim, which is fine for the file sizes its normally fed (always whole integers) // number verbatim, which is fine for the file sizes its normally fed (always whole integers)
// but not for a computed rate, so round to a whole byte first. // but not for a computed rate, so round to a whole byte first.
function formatRate(bytesPerSecond) { return `${formatBytes(Math.round(bytesPerSecond))}/s`; } function formatRate(bytesPerSecond) { return `${formatBytes(Math.round(bytesPerSecond))}/s`; }
function setHeroStat(key, { value, percent, detail, tone } = {}) { // Builds one hero panel's stat markup for a given prefix ("system-hero" on the Administration >
const valueEl = document.querySelector(`#system-hero-${key}-value`), fillEl = document.querySelector(`#system-hero-${key}-fill`), detailEl = document.querySelector(`#system-hero-${key}-detail`); // 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]) => `<div class="system-hero-stat" data-hero-stat="${key}"><span class="system-hero-label">${label}</span><strong class="system-hero-value" id="${prefix}-${key}-value">\u2014</strong><div class="system-hero-bar"><div class="system-hero-fill" id="${prefix}-${key}-fill"></div></div><small class="system-hero-detail" id="${prefix}-${key}-detail"></small></div>`).join("");
}
function setHeroStat(prefix, key, { value, percent, detail, tone } = {}) {
const valueEl = document.querySelector(`#${prefix}-${key}-value`), fillEl = document.querySelector(`#${prefix}-${key}-fill`), detailEl = document.querySelector(`#${prefix}-${key}-detail`);
if (valueEl) valueEl.textContent = value ?? "\u2014"; if (valueEl) valueEl.textContent = value ?? "\u2014";
if (fillEl) { fillEl.style.width = `${Math.max(0, Math.min(100, percent ?? 0))}%`; fillEl.className = `system-hero-fill${tone ? ` ${tone}` : ""}`; } if (fillEl) { fillEl.style.width = `${Math.max(0, Math.min(100, percent ?? 0))}%`; fillEl.className = `system-hero-fill${tone ? ` ${tone}` : ""}`; }
if (detailEl) detailEl.textContent = detail || ""; if (detailEl) detailEl.textContent = detail || "";
} }
// Populates the System tab's hero panel (CPU/memory/swap/disk/network/throughput) from // Populates a hero panel's CPU/memory/swap/disk/network stats (shared by both the System tab and
// /api/system/health. Each stat degrades gracefully to a dash when its source isn't available // the Dashboard) from /api/system/health. Each stat degrades gracefully to a dash when its source
// (e.g. no cgroup v2, no readable network interfaces, swap disabled on the host). // isn't available (e.g. no cgroup v2, no readable network interfaces, swap disabled on the host).
function renderSystemHealthHero(health) { // Throughput is System-tab-only -- the Dashboard already shows live requests/min in its own chip,
if (!document.querySelector("#system-hero-grid")) return; // so `includeThroughput: false` there skips it rather than showing the same number twice.
if (!health) { ["cpu", "memory", "swap", "disk", "network", "throughput"].forEach(key => setHeroStat(key, { value: "\u2014", detail: "Unavailable" })); return; } function renderHeroPanel(prefix, health, { includeThroughput } = { includeThroughput: true }) {
const keys = includeThroughput ? ["cpu", "memory", "swap", "disk", "network", "throughput"] : ["cpu", "memory", "swap", "disk", "network"];
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" : ""; const tone = percent => percent >= 90 ? "critical" : percent >= 75 ? "warning" : "";
if (health.cpu) { if (health.cpu) {
const quotaLabel = health.cpu.quotaSource === "quota" ? `Of ${health.cpu.quotaCpus} allocated CPU${health.cpu.quotaCpus === 1 ? "" : "s"}` : health.cpu.quotaSource === "pinned" ? `Of ${health.cpu.quotaCpus} pinned core${health.cpu.quotaCpus === 1 ? "" : "s"}` : `Of host\u2019s ${health.cpu.quotaCpus} core${health.cpu.quotaCpus === 1 ? "" : "s"} \u2014 no limit set`; const quotaLabel = health.cpu.quotaSource === "quota" ? `Of ${health.cpu.quotaCpus} allocated CPU${health.cpu.quotaCpus === 1 ? "" : "s"}` : health.cpu.quotaSource === "pinned" ? `Of ${health.cpu.quotaCpus} pinned core${health.cpu.quotaCpus === 1 ? "" : "s"}` : `Of host\u2019s ${health.cpu.quotaCpus} core${health.cpu.quotaCpus === 1 ? "" : "s"} \u2014 no limit set`;
setHeroStat("cpu", { value: `${health.cpu.percent.toFixed(1)}%`, percent: health.cpu.percent, tone: tone(health.cpu.percent), detail: quotaLabel }); setHeroStat(prefix, "cpu", { value: `${health.cpu.percent.toFixed(1)}%`, percent: health.cpu.percent, tone: tone(health.cpu.percent), detail: quotaLabel });
} }
else setHeroStat("cpu", { value: "\u2014", detail: "cgroup CPU stats unavailable" }); else setHeroStat(prefix, "cpu", { value: "\u2014", detail: "cgroup CPU stats unavailable" });
if (health.memory) setHeroStat("memory", { value: `${health.memory.percent.toFixed(1)}%`, percent: health.memory.percent, tone: tone(health.memory.percent), detail: `${formatBytes(health.memory.usedBytes)} / ${formatBytes(health.memory.limitBytes)}` }); if (health.memory) setHeroStat(prefix, "memory", { value: `${health.memory.percent.toFixed(1)}%`, percent: health.memory.percent, tone: tone(health.memory.percent), detail: `${formatBytes(health.memory.usedBytes)} / ${formatBytes(health.memory.limitBytes)}` });
else setHeroStat("memory", { value: "\u2014", detail: "cgroup memory stats unavailable" }); else setHeroStat(prefix, "memory", { value: "\u2014", detail: "cgroup memory stats unavailable" });
// Swap only gets a real percentage when the container has an actual --memory-swap limit set // Swap only gets a real percentage when the container has an actual --memory-swap limit set
// (memory.swap.max is a real number). Without one it's unbounded and shares the host's swap, // (memory.swap.max is a real number). Without one it's unbounded and shares the host's swap,
// so a raw "0 B" would read like a hard cap that doesn't exist -- say so instead. // so a raw "0 B" would read like a hard cap that doesn't exist -- say so instead.
if (health.swap && health.swap.configured === false) setHeroStat("swap", { value: "Off", percent: 0, detail: "Swap is not configured for this container" }); if (health.swap && health.swap.configured === false) setHeroStat(prefix, "swap", { value: "Off", percent: 0, detail: "Swap is not configured for this container" });
else if (health.swap && health.swap.limitBytes) setHeroStat("swap", { value: `${health.swap.percent.toFixed(1)}%`, percent: health.swap.percent, tone: tone(health.swap.percent), detail: `${formatBytes(health.swap.usedBytes)} / ${formatBytes(health.swap.limitBytes)}` }); else if (health.swap && health.swap.limitBytes) setHeroStat(prefix, "swap", { value: `${health.swap.percent.toFixed(1)}%`, percent: health.swap.percent, tone: tone(health.swap.percent), detail: `${formatBytes(health.swap.usedBytes)} / ${formatBytes(health.swap.limitBytes)}` });
else if (health.swap) setHeroStat("swap", { value: formatBytes(health.swap.usedBytes), percent: 0, detail: "Unlimited \u2014 shares host swap" }); else if (health.swap) setHeroStat(prefix, "swap", { value: formatBytes(health.swap.usedBytes), percent: 0, detail: "Unlimited \u2014 shares host swap" });
else setHeroStat("swap", { value: "\u2014", detail: "cgroup swap stats unavailable" }); else setHeroStat(prefix, "swap", { value: "\u2014", detail: "cgroup swap stats unavailable" });
if (health.disk) { if (health.disk) {
const overAssigned = health.disk.assignedLimitBytes && health.disk.percent > 100; const overAssigned = health.disk.assignedLimitBytes && health.disk.percent > 100;
const diskDetail = health.disk.assignedLimitBytes ? `${formatBytes(health.disk.usedBytes)} used of ${formatBytes(health.disk.assignedLimitBytes)} assigned` : `${formatBytes(health.disk.usedBytes)} used \u00b7 ${formatBytes(health.disk.availableBytes)} free`; const diskDetail = health.disk.assignedLimitBytes ? `${formatBytes(health.disk.usedBytes)} used of ${formatBytes(health.disk.assignedLimitBytes)} assigned` : `${formatBytes(health.disk.usedBytes)} used \u00b7 ${formatBytes(health.disk.availableBytes)} free`;
setHeroStat("disk", { value: `${health.disk.percent.toFixed(1)}%`, percent: Math.min(100, health.disk.percent), tone: overAssigned ? "critical" : tone(health.disk.percent), detail: diskDetail }); setHeroStat(prefix, "disk", { value: `${health.disk.percent.toFixed(1)}%`, percent: Math.min(100, health.disk.percent), tone: overAssigned ? "critical" : tone(health.disk.percent), detail: diskDetail });
} }
else setHeroStat("disk", { value: "\u2014", detail: "Disk stats unavailable" }); else setHeroStat(prefix, "disk", { value: "\u2014", detail: "Disk stats unavailable" });
if (health.network) setHeroStat("network", { value: formatRate(health.network.rxBytesPerSec + health.network.txBytesPerSec), percent: 0, detail: `\u2193 ${formatRate(health.network.rxBytesPerSec)} \u00b7 \u2191 ${formatRate(health.network.txBytesPerSec)}` }); 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("network", { value: "\u2014", detail: "Sampling\u2026" }); else setHeroStat(prefix, "network", { value: "\u2014", detail: "Sampling\u2026" });
setHeroStat("throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" }); if (includeThroughput) setHeroStat(prefix, "throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" });
} }
// --- System tab: environment/integration status, storage, scheduled jobs, sync, restart -------- // --- System tab: environment/integration status, storage, scheduled jobs, sync, restart --------
function renderSystemPanel() { function renderSystemPanel() {
@@ -626,11 +637,7 @@ function renderSystemPanel() {
panel.dataset.ready = "1"; panel.dataset.ready = "1";
panel.innerHTML = [ panel.innerHTML = [
'<div class="panel-heading"><div><h2>System</h2><p class="muted">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.</p></div></div>', '<div class="panel-heading"><div><h2>System</h2><p class="muted">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.</p></div></div>',
'<div class="system-hero"><div class="system-hero-grid" id="system-hero-grid">' + `<div class="system-hero"><div class="system-hero-grid" id="system-hero-grid">${heroSlotsMarkup("system-hero", [["cpu", "CPU"], ["memory", "Memory"], ["swap", "Swap"], ["disk", "Disk"], ["network", "Network"], ["throughput", "Throughput"]])}</div></div>`,
["cpu:CPU", "memory:Memory", "swap:Swap", "disk:Disk", "network:Network", "throughput:Throughput"].map(entry => { const [key, label] = entry.split(":");
return `<div class="system-hero-stat" data-hero-stat="${key}"><span class="system-hero-label">${label}</span><strong class="system-hero-value" id="system-hero-${key}-value">\u2014</strong><div class="system-hero-bar"><div class="system-hero-fill" id="system-hero-${key}-fill"></div></div><small class="system-hero-detail" id="system-hero-${key}-detail"></small></div>`;
}).join("") +
'</div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Integrations</h2></div></div><div id="system-env-status" class="health-grid"></div><div class="system-integrations"></div></div>', '<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Integrations</h2></div></div><div id="system-env-status" class="health-grid"></div><div class="system-integrations"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Security status</h2></div></div><div id="system-security" class="health-grid"></div></div>', '<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Security status</h2></div></div><div id="system-security" class="health-grid"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="health-grid"></div></div>', '<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="health-grid"></div></div>',
@@ -679,7 +686,7 @@ function renderSystemPanel() {
if (!state.systemHealthTimer) state.systemHealthTimer = setInterval(() => { if (!state.systemHealthTimer) state.systemHealthTimer = setInterval(() => {
const systemPanel = document.querySelector('[data-admin-panel="system"]'); const systemPanel = document.querySelector('[data-admin-panel="system"]');
if (state.view !== "administration" || !systemPanel || systemPanel.classList.contains("hidden")) return; if (state.view !== "administration" || !systemPanel || systemPanel.classList.contains("hidden")) return;
api("/api/system/health").then(renderSystemHealthHero).catch(() => {}); api("/api/system/health").then(health => renderHeroPanel("system-hero", health, { includeThroughput: true })).catch(() => {});
}, 7000); }, 7000);
} }
renderSystemStatus(panel); renderSystemStatus(panel);
@@ -702,7 +709,18 @@ async function renderSystemStatus(panel) {
envStatus.innerHTML = `<div class="health-tile"><span class="status-dot ${encryptionAvailable ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>BACKUP_PASSWORD</strong><small>${encryptionAvailable ? "Configured \u2014 scheduled backups can be encrypted." : "Not set \u2014 configure it in the container\u2019s environment to enable encrypted scheduled backups."}</small></span></div>`; envStatus.innerHTML = `<div class="health-tile"><span class="status-dot ${encryptionAvailable ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>BACKUP_PASSWORD</strong><small>${encryptionAvailable ? "Configured \u2014 scheduled backups can be encrypted." : "Not set \u2014 configure it in the container\u2019s environment to enable encrypted scheduled backups."}</small></span></div>`;
} }
if (syncStatus) { const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift"); syncStatus.textContent = drift ? "Configuration drift detected \u2014 the running gateway no longer matches the last known-good configuration." : `Gateway configuration is in sync. Last reload: ${state.dashboard?.gateway?.lastReload ? formatTime(state.dashboard.gateway.lastReload) : "unknown"}.`; syncStatus.className = drift ? "muted status-warning" : "muted"; } if (syncStatus) { const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift"); syncStatus.textContent = drift ? "Configuration drift detected \u2014 the running gateway no longer matches the last known-good configuration." : `Gateway configuration is in sync. Last reload: ${state.dashboard?.gateway?.lastReload ? formatTime(state.dashboard.gateway.lastReload) : "unknown"}.`; syncStatus.className = drift ? "muted status-warning" : "muted"; }
if (version) version.innerHTML = `Site Gateway v${extendedEscape(state.config?.version || "unknown")}<br>Access this dashboard at: <code>${extendedEscape(location.origin)}</code><br>Data directory: <code>${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")}</code> &middot; Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`; 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")}<br>Uptime: ${uptime} \u00b7 Database: ${database} \u00b7 Public IP: ${extendedEscape(publicIp)} (${publicIpDetail})<br>Access this dashboard at: <code>${extendedEscape(location.origin)}</code><br>Data directory: <code>${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")}</code> &middot; Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
}
try { try {
const [sec, store, policy, health] = await Promise.all([ const [sec, store, policy, health] = await Promise.all([
api("/api/system/security"), api("/api/system/security"),
@@ -710,7 +728,7 @@ async function renderSystemStatus(panel) {
api("/api/system/restart-policy"), api("/api/system/restart-policy"),
api("/api/system/health").catch(() => null), api("/api/system/health").catch(() => null),
]); ]);
renderSystemHealthHero(health); renderHeroPanel("system-hero", health, { includeThroughput: true });
if (security) security.innerHTML = [ 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.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." }, { 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." },
+10 -12
View File
@@ -8,7 +8,7 @@
<title>Site Gateway</title> <title>Site Gateway</title>
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard."> <meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png"> <link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
<link rel="stylesheet" href="/styles.css?v=0.16.33"> <link rel="stylesheet" href="/styles.css?v=0.16.35">
</head> </head>
<!-- ================================================================ <!-- ================================================================
@@ -132,16 +132,14 @@
</section> </section>
<section class="dashboard-panel system-panel"> <section class="dashboard-panel system-panel">
<div class="panel-heading"><div><p class="eyebrow">Runtime</p><h2>System</h2></div></div> <div class="panel-heading"><div><p class="eyebrow">Runtime</p><h2>System</h2></div></div>
<dl class="system-grid"> <div class="system-hero-grid" id="dashboard-hero-grid">
<div class="system-tile"><dt>Uptime</dt><dd id="system-uptime"></dd></div> <div class="system-hero-stat" data-hero-stat="cpu"><span class="system-hero-label">CPU</span><strong class="system-hero-value" id="dashboard-hero-cpu-value"></strong><div class="system-hero-bar"><div class="system-hero-fill" id="dashboard-hero-cpu-fill"></div></div><small class="system-hero-detail" id="dashboard-hero-cpu-detail"></small></div>
<div class="system-tile"><dt>Memory</dt><dd id="system-memory"></dd></div> <div class="system-hero-stat" data-hero-stat="memory"><span class="system-hero-label">Memory</span><strong class="system-hero-value" id="dashboard-hero-memory-value"></strong><div class="system-hero-bar"><div class="system-hero-fill" id="dashboard-hero-memory-fill"></div></div><small class="system-hero-detail" id="dashboard-hero-memory-detail"></small></div>
<div class="system-tile"><dt>Site Gateway data</dt><dd id="system-data"></dd><small>Used by sites and configuration</small></div> <div class="system-hero-stat" data-hero-stat="swap"><span class="system-hero-label">Swap</span><strong class="system-hero-value" id="dashboard-hero-swap-value"></strong><div class="system-hero-bar"><div class="system-hero-fill" id="dashboard-hero-swap-fill"></div></div><small class="system-hero-detail" id="dashboard-hero-swap-detail"></small></div>
<div class="system-tile"><dt>Storage available</dt><dd id="system-disk"></dd><small>Available on the /data volume</small></div> <div class="system-hero-stat" data-hero-stat="disk"><span class="system-hero-label">Disk</span><strong class="system-hero-value" id="dashboard-hero-disk-value"></strong><div class="system-hero-bar"><div class="system-hero-fill" id="dashboard-hero-disk-fill"></div></div><small class="system-hero-detail" id="dashboard-hero-disk-detail"></small></div>
<div class="system-tile"><dt>Site Gateway</dt><dd id="system-app-version"></dd></div> <div class="system-hero-stat" data-hero-stat="network"><span class="system-hero-label">Network</span><strong class="system-hero-value" id="dashboard-hero-network-value"></strong><div class="system-hero-bar"><div class="system-hero-fill" id="dashboard-hero-network-fill"></div></div><small class="system-hero-detail" id="dashboard-hero-network-detail"></small></div>
<div class="system-tile"><dt>Caddy</dt><dd id="system-caddy-version"></dd></div> <div class="system-hero-stat" data-hero-stat="uptime"><span class="system-hero-label">Uptime</span><strong class="system-hero-value" id="system-uptime"></strong><div class="system-hero-bar"></div><small class="system-hero-detail">Since last restart</small></div>
<div class="system-tile"><dt>Database</dt><dd id="system-database"></dd><small id="system-database-detail">SQLite storage</small></div> </div>
<div class="system-tile"><dt>Public IP</dt><dd id="system-public-ip"></dd><small id="system-public-ip-detail">Not yet checked</small></div>
</dl>
</section> </section>
</div> </div>
<div id="dashboard-jobs-slot" class="dashboard-jobs-slot"></div> <div id="dashboard-jobs-slot" class="dashboard-jobs-slot"></div>
@@ -434,6 +432,6 @@
<div id="toast" class="toast" role="status"></div> <div id="toast" class="toast" role="status"></div>
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div> <div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
<!-- App scripts: core (app.js) then extended views/admin (features.js) --> <!-- App scripts: core (app.js) then extended views/admin (features.js) -->
<script src="/app.js?v=0.16.33" defer></script><script src="/features.js?v=0.16.33" defer></script><script src="/select-enhance.js?v=0.16.33" defer></script> <script src="/app.js?v=0.16.35" defer></script><script src="/features.js?v=0.16.35" defer></script><script src="/select-enhance.js?v=0.16.35" defer></script>
</body> </body>
</html> </html>
+7 -1
View File
@@ -989,7 +989,13 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.system-hero-fill{height:100%;border-radius:var(--radius-full);background:var(--green);transition:width .4s ease} .system-hero-fill{height:100%;border-radius:var(--radius-full);background:var(--green);transition:width .4s ease}
.system-hero-fill.warning{background:var(--warning)} .system-hero-fill.warning{background:var(--warning)}
.system-hero-fill.critical{background:var(--danger)} .system-hero-fill.critical{background:var(--danger)}
.system-hero-stat[data-hero-stat="network"] .system-hero-bar,.system-hero-stat[data-hero-stat="throughput"] .system-hero-bar{display:none} .system-hero-stat[data-hero-stat="network"] .system-hero-bar,.system-hero-stat[data-hero-stat="throughput"] .system-hero-bar,.system-hero-stat[data-hero-stat="uptime"] .system-hero-bar{visibility:hidden}
.system-hero-detail{color:var(--muted);font-size:var(--font-size-xs);min-height:1.2em} .system-hero-detail{color:var(--muted);font-size:var(--font-size-xs);min-height:1.2em}
@media(max-width:1100px){.system-hero-grid{grid-template-columns:repeat(3,minmax(0,1fr))}} @media(max-width:1100px){.system-hero-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:420px){.system-hero-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:420px){.system-hero-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
/* The Dashboard's copy of the hero sits inside .dashboard-columns' half-width column, not the
Administration tab's full-width panel, so the same viewport-based breakpoints above would keep
it at 6 columns on an ordinary desktop window even though its actual available width is much
narrower -- collapse it a step earlier, keyed to its own id rather than the viewport. */
#dashboard-hero-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
@media(max-width:900px){#dashboard-hero-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}