Compare commits

..

4 Commits

7 changed files with 56 additions and 24 deletions
+3 -3
View File
@@ -12,7 +12,7 @@
<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="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.35-62E6A7">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.39-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
@@ -40,14 +40,14 @@ It's intentionally narrower than a general-purpose proxy manager. You describe *
| Upload a ZIP or `index.html` and publish static files on a domain and/or a direct port | Point a domain at Plex, Jellyfin, Vaultwarden, or any HTTP app — TLS, HSTS, and headers included | Send one or more domains to a canonical destination with 301/302/307/308 | Forward raw TCP/UDP ports straight to a service — game servers, SSH, anything that isn't HTTP |
- **Automatic HTTPS** — Caddy issues and renews public certificates; internal, HTTP-only, and uploaded custom-certificate modes are also supported.
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, throughput, uptime, memory, disk, and version info at a glance.
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, and throughput at a glance, plus a live resource panel (CPU, memory, swap, disk, network, uptime) reading real container-scoped cgroup v2 stats, not host-wide numbers, and auto-refreshing while the page is open.
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes, plus an administrator-side override to disable a locked-out user's 2FA when they've lost their authenticator and used up their recovery codes.
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
- **API access tokens** — issue scoped (full-access or read-only), optionally expiring bearer tokens for scripts and integrations, revocable at any time.
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
- **Performance and logs** — per-domain request throughput, response times, and rotating access/activity logs, including a System page with environment/integration status, gateway sync, scheduled jobs, and storage usage.
- **Performance and logs** — per-domain request throughput, response times, and rotating access/activity logs, including a System page (Administration) with the same live resource panel as the Dashboard, environment/integration status, gateway sync, scheduled jobs, storage usage, and version/database/public IP details.
- **SQLite-backed persistence** — no external database container; everything lives under one `/data` volume.
Hosted uploads remain static-only (HTML, CSS, JS, images, fonts, downloads). Dynamic applications are connected as Proxy Hosts instead — Site Gateway does not execute uploaded PHP, Node, Python, or database code.
+9
View File
@@ -196,3 +196,12 @@ Roughly in priority order:
`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.
`v0.16.36` opens `/api/system/health` up to every signed-in user instead of administrators only, so the Dashboard's hero panel (unified with the System tab's in v0.16.35) actually populates for standard users instead of sitting on dashes forever. It's a read-only endpoint with nothing destructive or sensitive behind it -- live CPU/memory/swap/disk/network numbers a standard user could already roughly infer from the Dashboard running fast or slow -- so it now follows the same no-admin-gate pattern as `/api/dashboard` rather than the stricter pattern used by the rest of `/api/system/*` (storage breakdown, restart, restart-policy), which stay administrator-only since those are either configuration detail or capable of restarting the container. Worth keeping in mind: when `DATA_DIR_LIMIT_GB` is set, each poll of this endpoint does a real recursive walk of `/data` to compute Site Gateway's own footprint (see v0.16.32) -- with multiple people viewing the Dashboard at once, each on their own 7-second timer, that's now multiple concurrent walks instead of one administrator's. Not a problem at ordinary usage levels, but worth revisiting (e.g. a shared, briefly-cached snapshot) if it's ever noticeably heavy with a lot of concurrent viewers.
`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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.16.35",
"version": "0.16.39",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+20 -10
View File
@@ -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 = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
// Dashboard tiles share one baseline accent (green) and switch to the existing
// --warning / --danger tokens when the thing they count is actually in trouble --
// the same mechanism the "Needs attention" chip already used.
@@ -214,7 +213,6 @@ function renderDashboard() {
$("#upstream-health-dot").className = `status-dot ${!upstreams.total ? "inactive" : upstreams.unhealthy > 0 ? "error" : "running"}`;
$("#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)}`;
updateDashboardUptime(data.system.uptimeSeconds);
// 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/
@@ -228,7 +226,6 @@ function renderDashboard() {
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
}
setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000);
// --- Card rendering helpers (icons, permissions) -----------------------------------
@@ -550,13 +547,24 @@ 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 }); }
try {
const health = await api("/api/system/health");
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 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;
}
catch { /* Hero keeps its last-known values if a poll fails -- same behavior as the System tab's own hero. */ }
}
@@ -578,9 +586,11 @@ async function boot() {
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);
// hero uses, gated on the Dashboard actually being the visible view. Available to every
// signed-in user, not just administrators -- /api/system/health is read-only and shows
// nothing a standard user couldn't already infer from the Dashboard running slow or fast.
if (state.view === "overview") refreshDashboardHero().catch(() => {});
if (!state.dashboardHeroTimer) state.dashboardHeroTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboardHero().catch(() => {}); }, 7000);
}
async function checkForUpdate() {
+10 -5
View File
@@ -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." },
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -222,6 +222,10 @@ async function systemHealthSnapshot() {
})() : null,
network: networkRate,
throughput: { liveRequests: storage.performanceLiveCount(60) },
// Same source dashboardSnapshot() already uses for its own uptime figure -- included here too
// so the Dashboard's hero panel can show Uptime as a plain value on the same 7s poll as every
// other hero stat, instead of a separate client-side ticker anchored against a one-time fetch.
uptimeSeconds: Math.floor(process.uptime()),
};
}
@@ -1713,8 +1717,12 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) }, docker: { socketMounted: dockerSocketMounted, enabled: dockerSocketMounted && settings.dockerIntegration?.enabled === true } }));
// --- System tab: storage usage, restart-policy check, and self-restart -----------------------------------
// Read-only, non-destructive live resource stats (CPU/memory/swap/disk/network/throughput) --
// shown on the Dashboard for every signed-in user, same as the rest of the Dashboard's health
// panel, and additionally on the Administration > System tab's hero for administrators. Unlike
// most /api/system/* routes this intentionally isn't administrator-gated, since there's nothing
// here a standard user couldn't already infer from the Dashboard being slow or fast.
app.get("/api/system/health", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try { res.json(await systemHealthSnapshot()); } catch (error) { next(error); }
});
app.get("/api/system/storage", async (req, res, next) => {