Show page-scoped refresh button on every page, sized to match primary buttons

This commit is contained in:
marvin
2026-09-20 00:05:02 -04:00
parent 7d5842448e
commit 6cd51a8a3f
6 changed files with 13 additions and 10 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="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.45-62E6A7">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.47-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
+2
View File
@@ -218,3 +218,5 @@ Roughly in priority order:
`v0.16.44` is a temporary, diagnostic-only release -- no behavior changes, just logging -- added after v0.16.43 (which fixed the app from over-fetching per page) didn't resolve the user's reported 6-14 second page loads. A Network-tab Timing capture the user sent for a single `GET /api/sites` request showed DNS and TCP connection at 0-7ms but "Waiting" (time to first byte) at 7485ms -- almost the entire delay happened server-side, before the app sent back a single byte of what should be a near-instant, in-memory list. Since this codebase's database and JS execution is single-threaded, that pattern (a trivially cheap request taking seconds) points to something else blocking the whole process at that moment, not a cost specific to any one endpoint. The leading suspect: `importAccessLogsToSqlite()`, a job that runs every 30 seconds, reads Caddy's access-log files, JSON-parses and hashes up to 5000 lines, and batch-inserts them -- all synchronous work with nothing to yield the event loop partway through. Rather than ship a fourth guess-based fix, this release adds two pieces of logging visible in the container's own logs: a warning whenever that import job takes over 500ms (broken down into read/hash/insert time), and a warning whenever any request takes over 1 second to answer. The next slow page load should show, in the logs, either the import job's duration lining up with the slow request's timestamp (confirming the suspect) or a different pattern entirely (pointing somewhere else). Both log lines are marked as temporary instrumentation, intended to be removed once the real cause is confirmed and fixed.
`v0.16.45` fixes the confirmed root cause behind the multi-second page loads reported after v0.16.41-v0.16.43: the user's own container logs, captured with v0.16.44's temporary diagnostics, showed completely unrelated endpoints -- `/api/dashboard`, `/api/system/security`, `/api/logs/prune/preview` -- all finishing within moments of each other at nearly identical ~8.5-9 second durations, right after the container started. That pattern only happens when several requests are queued behind one shared blocking operation, not when each is independently slow. The culprit: `dashboardSnapshot()` (which every `/api/dashboard` fetch runs) called `storage.integrity()` -- a full `PRAGMA integrity_check`, a complete scan of the entire SQLite database file for corruption, one of the most expensive operations SQLite can run -- on every single call, purely to compute one cosmetic "Healthy"/"Needs attention" label. Because this app's SQLite queries run synchronously, that scan didn't just make its own request slow, it froze the entire single-threaded server for its whole duration, on every dashboard fetch, for every user. The fix moves that check off the request path entirely: a new `refreshDatabaseIntegrityCache()` runs the real scan once shortly after startup and then every 30 minutes in the background, caching just the resulting status string, and `dashboardSnapshot()` now reads that cached value instantly instead of re-scanning the whole database on every poll. The (rarely-used, explicitly manual) downloadable support report still runs a live, real-time integrity check, since that's an appropriate place for a slow, thorough scan. v0.16.44's temporary `[perf]` logging stays in place for this release so the fix's effect is directly visible in the container's own logs -- expect no more `[perf] GET ... took` warnings tied to `/api/dashboard` going forward.
`v0.16.47` makes the page-scoped refresh button (added in v0.16.43) consistent across every view instead of appearing on most pages but not Logs, and removes a now-redundant control. The button is repositioned to always sit top-right, immediately to the right of that page's green primary action button (“+ New hosted site”, “Run certificate check”, “Refresh logs”) when one is present, or in that same top-right spot when a page has no primary action button of its own; it now also appears on the Logs page rather than being hidden there. A dedicated CSS rule (`.page-refresh{width:44px;height:44px}`) makes the button exactly the same height as the app's existing 44px primary-button standard (the same convention already used for the Backups and Retention action rows), so it visually lines up with the button beside it instead of looking undersized next to it. The Live Health panel's own separate “↻” refresh icon has been removed from the Dashboard, since the page-level refresh button sitting a few pixels away now does the identical job (`refreshDashboard()`, which repopulates that same panel); `refreshDashboard()` itself is unchanged and still runs on its normal 30-second Overview timer, it just no longer drives a second, separate icon's spinner.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.16.45",
"version": "0.16.47",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+3 -4
View File
@@ -493,7 +493,7 @@ function render() {
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
const activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups", adminApiActive = activeAdminTab === "api";
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || adminApiActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates" || !canAdmin()); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs"); $("#refresh-view").classList.toggle("hidden", state.view === "logs");
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || adminApiActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates" || !canAdmin()); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
if (overview) {
$("#page-title").textContent = "Dashboard";
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
@@ -606,9 +606,9 @@ async function refreshPendingProxies(ids = []) {
}
}
async function refreshDashboard() {
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
$("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
finally { button.disabled = false; button.classList.remove("spinning"); }
finally { /* no-op: the Live Health panel's own refresh icon was removed in favor of the page-level refresh button */ }
}
// 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
@@ -769,7 +769,6 @@ document.addEventListener("keydown", event => { if (event.key === "Escape") clos
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message, "error")));
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refreshCurrentView(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refreshCurrentView(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
+4 -4
View File
@@ -8,7 +8,7 @@
<title>Site Gateway</title>
<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="stylesheet" href="/styles.css?v=0.16.45">
<link rel="stylesheet" href="/styles.css?v=0.16.47">
</head>
<!-- ================================================================
@@ -101,7 +101,7 @@
</nav>
<header>
<div><p class="eyebrow">Gateway control</p><h1 id="page-title">Dashboard</h1><p id="page-subtitle" class="muted">Health, activity, and system status at a glance.</p></div>
<button id="refresh-view" class="icon-button" aria-label="Refresh this page" title="Refresh this page"></button><button id="open-create" class="button primary"> New hosted site</button><button id="check-health" class="button primary hidden">Run certificate check</button><button id="refresh-logs" class="button primary hidden">Refresh logs</button>
<button id="open-create" class="button primary"> New hosted site</button><button id="check-health" class="button primary hidden">Run certificate check</button><button id="refresh-logs" class="button primary hidden">Refresh logs</button><button id="refresh-view" class="icon-button page-refresh" aria-label="Refresh this page" title="Refresh this page"></button>
</header>
<!-- Overview / Dashboard: health, activity, system stats -->
@@ -119,7 +119,7 @@
</div>
<div class="dashboard-columns">
<section class="dashboard-panel health-panel status-healthy" id="health-panel">
<div class="panel-heading"><div><p class="eyebrow">Live health</p><h2>Services</h2></div><div class="health-actions"><span id="overall-health" class="health-badge healthy">Healthy</span><button id="refresh-health" class="icon-button" aria-label="Refresh health checks" title="Refresh health checks"></button></div></div>
<div class="panel-heading"><div><p class="eyebrow">Live health</p><h2>Services</h2></div><div class="health-actions"><span id="overall-health" class="health-badge healthy">Healthy</span></div></div>
<div class="health-grid">
<div class="health-tile"><span id="gateway-health-dot" class="status-dot running"></span><span class="health-tile-copy"><strong>Gateway</strong><small id="gateway-health-copy">Configuration valid</small></span></div>
<div class="health-tile"><span id="http-health-dot" class="status-dot running"></span><span class="health-tile-copy"><strong>HTTP · Port 80</strong><small id="http-health-copy">Ready and responding</small></span></div>
@@ -432,6 +432,6 @@
<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>
<!-- App scripts: core (app.js) then extended views/admin (features.js) -->
<script src="/app.js?v=0.16.45" defer></script><script src="/features.js?v=0.16.45" defer></script><script src="/select-enhance.js?v=0.16.45" defer></script>
<script src="/app.js?v=0.16.47" defer></script><script src="/features.js?v=0.16.47" defer></script><script src="/select-enhance.js?v=0.16.47" defer></script>
</body>
</html>
+2
View File
@@ -64,6 +64,8 @@ h2{letter-spacing:-.025em}
.card-footer{position:absolute;left:20px;right:20px;bottom:20px}
.status-pill{display:flex;align-items:center;gap:var(--space-2);text-transform:capitalize;font-size:var(--font-size-sm);color:var(--muted)}
.icon-button,.launch{width:34px;height:34px;border-radius:var(--radius-sm);border:1px solid var(--line);display:grid;place-items:center;background:var(--icon-button-bg);color:var(--muted);cursor:pointer;text-decoration:none}
.page-refresh{width:44px;height:44px;flex:0 0 auto}
header .page-refresh{margin-left:8px}
.menu-wrap{position:relative}
.menu{display:none;position:absolute;right:0;top:var(--space-7);width:145px;background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-2xs);padding:6px;box-shadow:var(--shadow);z-index:3}
.menu-open .menu{display:block}