Scope page refreshes to only the data each view needs

This commit is contained in:
marvin
2026-09-19 22:57:17 -04:00
parent 926296d0d7
commit bf524e5835
5 changed files with 82 additions and 12 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.42-62E6A7"> <img alt="Version" src="https://img.shields.io/badge/version-0.16.43-62E6A7">
</p> </p>
<p> <p>
<a href="#why-site-gateway">Why Site Gateway</a> · <a href="#why-site-gateway">Why Site Gateway</a> ·
+2
View File
@@ -212,3 +212,5 @@ Roughly in priority order:
`v0.16.41` cuts redundant work out of the app's shared `refresh()` cycle -- the single function that populates nearly every page (Hosted Sites, Proxy Hosts, Redirects, Streams, Access Lists, Dashboard, and Certificates all pull from it) -- after a user reported the whole site feeling slow to refresh, most concretely on a plain reload of Hosted Sites or Proxy Hosts. Two real causes, found by reading the actual request path rather than guessing: first, `certificateInventory()` (which walks the certificate directories and parses every `.crt`/`.pem` file on disk) was being fully recomputed from scratch on every single call, and `refresh()` calls it twice per cycle -- once via `/api/dashboard`, once via `/api/certificates` -- so a normal page load did that walk-and-parse work twice for identical results. It now carries a short (3 second) in-memory cache, well under the 7-second hero-poll interval, so back-to-back calls within a cycle share one real disk walk instead of two, and nothing on screen goes more than one cycle stale. Second, and the bigger one: `refreshPendingProxies()` -- triggered whenever a page loads with any enabled proxy that doesn't have cached upstream-health data yet, which is the common case right after a page load or a new proxy -- was calling the *entire* `refresh()` again at +1s, +2s, and +3s until every proxy's health came back. That meant a single pending proxy could quietly trigger three additional full 8-endpoint refetches (each with its own pair of certificate walks) in the six seconds after a page appeared to have finished loading. It now re-fetches only `/api/proxies` on those retries, since upstream health is all it was ever waiting on. Together these remove the two largest sources of duplicated, unnecessary work from the most-used code path in the app; whether they fully account for the reported slowness or whether a client-side rendering cost remains to find is still open and being evaluated against a real before/after comparison. `v0.16.41` cuts redundant work out of the app's shared `refresh()` cycle -- the single function that populates nearly every page (Hosted Sites, Proxy Hosts, Redirects, Streams, Access Lists, Dashboard, and Certificates all pull from it) -- after a user reported the whole site feeling slow to refresh, most concretely on a plain reload of Hosted Sites or Proxy Hosts. Two real causes, found by reading the actual request path rather than guessing: first, `certificateInventory()` (which walks the certificate directories and parses every `.crt`/`.pem` file on disk) was being fully recomputed from scratch on every single call, and `refresh()` calls it twice per cycle -- once via `/api/dashboard`, once via `/api/certificates` -- so a normal page load did that walk-and-parse work twice for identical results. It now carries a short (3 second) in-memory cache, well under the 7-second hero-poll interval, so back-to-back calls within a cycle share one real disk walk instead of two, and nothing on screen goes more than one cycle stale. Second, and the bigger one: `refreshPendingProxies()` -- triggered whenever a page loads with any enabled proxy that doesn't have cached upstream-health data yet, which is the common case right after a page load or a new proxy -- was calling the *entire* `refresh()` again at +1s, +2s, and +3s until every proxy's health came back. That meant a single pending proxy could quietly trigger three additional full 8-endpoint refetches (each with its own pair of certificate walks) in the six seconds after a page appeared to have finished loading. It now re-fetches only `/api/proxies` on those retries, since upstream health is all it was ever waiting on. Together these remove the two largest sources of duplicated, unnecessary work from the most-used code path in the app; whether they fully account for the reported slowness or whether a client-side rendering cost remains to find is still open and being evaluated against a real before/after comparison.
`v0.16.42` finds and fixes the real, dominant cause of the site-wide slowness reported after v0.16.41: a live Network-tab capture from the user's own browser showed a flood of requests to `/api/logs/prune/preview`, some queued for over 15 seconds, with unrelated requests (`/api/dashboard`, `/api/system/health`, `/api/system/security`, `/api/system/storage`) stuck at nearly identical multi-second times in the same batch -- the signature of one blocking operation stalling everything behind it, not several independently slow endpoints. Root cause: `renderRetentionPreview()`'s `setInterval(..., 2000)` polls that endpoint every 2 seconds forever, on every page of the app, not just Administration -> Logs & retention, because its "does the panel exist" guard checks a `<section>` that's written into `index.html` from page load and only ever CSS-hidden -- so the guard was always true, everywhere. There was also no protection against a new poll firing while a previous one was still in flight, so once the server answered slower than 2 seconds even once, requests piled up and never caught back up. Compounding it: `previewPruneEvents()` runs five synchronous SQLite COUNT queries, and one of them (`audit_events`) had no index at all -- a full table scan, every call -- and because this app's SQLite queries run synchronously, that scan doesn't just slow its own request, it blocks the entire Node process for every other request being served at that moment. Fixed on both sides: `renderRetentionPreview()` and the sibling `renderRetentionRunStatus()` (previously also running unconditionally every 500ms) now check that the retention panel is actually visible, not just present in the DOM, before doing any work, and an in-flight guard stops a new preview poll from starting until the last one has landed; `audit_events` now has the same `(instance_id, created_at)` index every sibling events table already had. Together these should remove the vast majority of the "8-10 seconds to load a simple page" behavior reported after v0.16.41 -- that fix (deduplicating certificate-inventory work) was real but minor by comparison to this one. `v0.16.42` finds and fixes the real, dominant cause of the site-wide slowness reported after v0.16.41: a live Network-tab capture from the user's own browser showed a flood of requests to `/api/logs/prune/preview`, some queued for over 15 seconds, with unrelated requests (`/api/dashboard`, `/api/system/health`, `/api/system/security`, `/api/system/storage`) stuck at nearly identical multi-second times in the same batch -- the signature of one blocking operation stalling everything behind it, not several independently slow endpoints. Root cause: `renderRetentionPreview()`'s `setInterval(..., 2000)` polls that endpoint every 2 seconds forever, on every page of the app, not just Administration -> Logs & retention, because its "does the panel exist" guard checks a `<section>` that's written into `index.html` from page load and only ever CSS-hidden -- so the guard was always true, everywhere. There was also no protection against a new poll firing while a previous one was still in flight, so once the server answered slower than 2 seconds even once, requests piled up and never caught back up. Compounding it: `previewPruneEvents()` runs five synchronous SQLite COUNT queries, and one of them (`audit_events`) had no index at all -- a full table scan, every call -- and because this app's SQLite queries run synchronously, that scan doesn't just slow its own request, it blocks the entire Node process for every other request being served at that moment. Fixed on both sides: `renderRetentionPreview()` and the sibling `renderRetentionRunStatus()` (previously also running unconditionally every 500ms) now check that the retention panel is actually visible, not just present in the DOM, before doing any work, and an in-flight guard stops a new preview poll from starting until the last one has landed; `audit_events` now has the same `(instance_id, created_at)` index every sibling events table already had. Together these should remove the vast majority of the "8-10 seconds to load a simple page" behavior reported after v0.16.41 -- that fix (deduplicating certificate-inventory work) was real but minor by comparison to this one.
`v0.16.43` scopes page refreshes to the page actually being viewed, instead of every refresh across the entire app unconditionally re-fetching everything -- Hosted Sites, Proxy Hosts, Redirects, Streams, Access Lists, Groups, the full Dashboard snapshot, and Certificates -- regardless of which single page triggered it. This was confirmed directly from the user's own account of the behavior ("if I'm on Hosted Sites and click refresh, it appears the whole entire site refreshes") and traced to a single shared `refresh()` function that every action in the app called: creating or editing a hosted site or proxy, toggling one on or off, deleting an entry, saving gateway settings, and re-syncing the gateway all ran the identical 8-endpoint fetch no matter which page initiated it. `refresh()` and its endpoints are now built from one shared map (`REFRESH_ENDPOINTS`), and a new `refreshCurrentView()` fetches only the state keys a `VIEW_REFRESH_KEYS` table says the active view actually renders -- Hosted Sites now refetches just `sites`, Proxy Hosts just `proxies`, Streaming just `streams`, Redirects just `redirects`, Access Lists just `accessLists` and `groups`. Every action listed above that's only ever reachable from one specific view (creating/editing/toggling/deleting a hosted site or proxy) now calls `refreshCurrentView()` instead of the full `refresh()`. Overview keeps the full, unscoped fetch deliberately: its attention list and the sidebar's per-section counts summarize the whole gateway, not one section of it, so scoping it would defeat the page's purpose; the initial page load (`boot()`) and the gateway re-sync button (only reachable from Overview) are unchanged for the same reason. A new generic refresh button (the same "↻" icon `refresh-health` already used) now appears on every page except Logs (which keeps its own dedicated "Refresh logs" button) so every view has an explicit, page-scoped way to pull fresh data without a full browser reload -- previously several views (Hosted, Proxy Hosts, Streaming, Redirects, Access Lists) had no refresh control of their own at all and only ever picked up new data from the page's initial load or the next full-page reload. One deliberate trade-off: sidebar badge counts for sections other than the one currently being viewed are not part of a scoped refresh and can go briefly stale until the next full refresh (a fresh page load, or a visit to Overview) -- intentional, since fetching data a page doesn't display was the entire problem being fixed here.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "site-gateway", "name": "site-gateway",
"version": "0.16.42", "version": "0.16.43",
"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",
+75 -7
View File
@@ -123,7 +123,7 @@ document.addEventListener("submit", async event => {
try { try {
await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); } if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); }
$("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); $("#settings-dialog").close(); await refreshCurrentView(); toast("Gateway settings applied.");
} }
catch (error) { $("#settings-error").textContent = error.message; } catch (error) { $("#settings-error").textContent = error.message; }
finally { button.disabled = false; } finally { button.disabled = false; }
@@ -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"); $("#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 activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups", adminApiActive = activeAdminTab === "api"; 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"); $("#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");
if (overview) { if (overview) {
$("#page-title").textContent = "Dashboard"; $("#page-title").textContent = "Dashboard";
$("#page-subtitle").textContent = "Health, activity, and system status at a glance."; $("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
@@ -532,7 +532,60 @@ function render() {
} }
// --- Data refresh helpers ------------------------------------------------------------------ // --- Data refresh helpers ------------------------------------------------------------------
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/streams"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "streams", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } } // Each entry is the state key a call populates and the fetch that populates it. refresh() (the
// full, unscoped fetch) and refreshCurrentView() (the page-scoped fetch, see below) both build
// their request list from this single map, so adding a new piece of shared state only ever means
// adding one line here.
const REFRESH_ENDPOINTS = {
sites: () => api("/api/sites"),
proxies: () => api("/api/proxies"),
redirects: () => api("/api/redirects"),
streams: () => api("/api/streams"),
accessLists: () => api("/api/access-lists"),
groups: () => canAdmin() ? api("/api/groups") : Promise.resolve([]),
dashboard: () => api("/api/dashboard"),
certificates: () => api("/api/certificates"),
};
// Which of the keys above each view actually renders. A view not listed here (certificates, logs,
// performance, administration, account, documentation) already loads its own data separately via
// loadFeatureView() and never called refresh() at all, so it isn't included. Overview intentionally
// lists everything: its attention list and the sidebar's per-section counts summarize the whole
// gateway, not one section of it, so a scoped fetch there would defeat the point of the page.
const VIEW_REFRESH_KEYS = {
overview: Object.keys(REFRESH_ENDPOINTS),
hosted: ["sites"],
proxies: ["proxies"],
streaming: ["streams"],
redirects: ["redirects"],
access: ["accessLists", "groups"],
};
async function refreshKeys(keys) {
const results = await Promise.allSettled(keys.map(key => REFRESH_ENDPOINTS[key]()));
results.forEach((result, index) => { if (result.status === "fulfilled") state[keys[index]] = result.value; });
}
function maybeRefreshPendingProxies() {
const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id);
if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); }
}
// The original, unscoped refresh -- fetches every shared list plus the dashboard and certificate
// summaries in one pass. Kept for cases that genuinely need everything at once: first page load
// (boot()) and the Overview page, whose attention list and counts summarize the entire gateway.
async function refresh() { await refreshKeys(Object.keys(REFRESH_ENDPOINTS)); state.loaded = true; render(); window.renderExtendedViews?.(); maybeRefreshPendingProxies(); }
// The page-scoped refresh: fetches only the state a given view actually renders, instead of
// unconditionally re-fetching sites, proxies, redirects, streams, access lists, groups, the full
// dashboard snapshot, and certificates every single time -- regardless of which one page the user
// is looking at. This was the original, most direct cause behind "refreshing one page refetches
// the whole site": every action (create, edit, toggle, delete) and every manual refresh called the
// same all-8-endpoints refresh() no matter which view triggered it. Sidebar badge counts for
// sections other than the current view are not re-fetched by this path and can go briefly stale
// until the next full refresh() (a fresh page load, or a visit to Overview) -- an intentional
// trade for not fetching data the current page doesn't display.
async function refreshCurrentView() {
const keys = VIEW_REFRESH_KEYS[state.view] || Object.keys(REFRESH_ENDPOINTS);
await refreshKeys(keys);
state.loaded = true; render(); window.renderExtendedViews?.();
if (keys.includes("proxies")) maybeRefreshPendingProxies();
}
// Polls just /api/proxies for upstream health that wasn't ready yet on the last refresh() -- // Polls just /api/proxies for upstream health that wasn't ready yet on the last refresh() --
// e.g. right after a page load or a new proxy, before its first health check has completed. // e.g. right after a page load or a new proxy, before its first health check has completed.
// This used to call the full refresh() (all 8 endpoints, including two redundant certificate // This used to call the full refresh() (all 8 endpoints, including two redundant certificate
@@ -645,6 +698,21 @@ $("#dashboard-view").addEventListener("click", event => { const target = event.t
// --- Logs & Performance filter controls ----------------------------------------------------- // --- Logs & Performance filter controls -----------------------------------------------------
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error")));
// Generic page-scoped refresh button, shown on every view except Logs (which already has its own
// "Refresh logs" button wired to loadFeatureView()). Uses refreshCurrentView() for the shared-list
// views (Overview, Hosted, Proxy Hosts, Streaming, Redirects, Access Lists) so it fetches only
// what that page renders, and falls back to loadFeatureView() for every other view (Certificates,
// Performance, Administration, Account, Documentation), which already load their own data scoped
// to themselves.
$("#refresh-view").addEventListener("click", async () => {
const button = $("#refresh-view"); button.disabled = true; button.classList.add("spinning");
try {
if (state.view in VIEW_REFRESH_KEYS || state.view === "overview") await refreshCurrentView();
else await loadFeatureView();
toast("Refreshed.");
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; button.classList.remove("spinning"); }
});
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
@@ -702,8 +770,8 @@ document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("c
// --- Hosted Sites & Proxy Hosts: create form submit handlers -------------------------------- // --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message, "error"))); $("#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 refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } }); $("#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 refresh(); 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"; } }); $("#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"; } });
// --- Health-check field visibility polish for the create forms ------------------------------ // --- Health-check field visibility polish for the create forms ------------------------------
@@ -737,7 +805,7 @@ $("#site-grid").addEventListener("click", async event => {
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind; const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return; if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
closeMenus(); closeMenus();
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } } if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refreshCurrentView(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } }
if (action === "settings") openSettings(kind, card.dataset.id); if (action === "settings") openSettings(kind, card.dataset.id);
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); } if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); } if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
@@ -781,7 +849,7 @@ window.openCaddyConfig = openCaddyConfig;
// --- Delete confirmation dialog and replace-files handler ------------------------------------ // --- Delete confirmation dialog and replace-files handler ------------------------------------
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; }); $("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refreshCurrentView(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; }); $("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; });
+3 -3
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.42"> <link rel="stylesheet" href="/styles.css?v=0.16.43">
</head> </head>
<!-- ================================================================ <!-- ================================================================
@@ -101,7 +101,7 @@
</nav> </nav>
<header> <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> <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="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" 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>
</header> </header>
<!-- Overview / Dashboard: health, activity, system stats --> <!-- Overview / Dashboard: health, activity, system stats -->
@@ -432,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.42" defer></script><script src="/features.js?v=0.16.42" defer></script><script src="/select-enhance.js?v=0.16.42" defer></script> <script src="/app.js?v=0.16.43" defer></script><script src="/features.js?v=0.16.43" defer></script><script src="/select-enhance.js?v=0.16.43" defer></script>
</body> </body>
</html> </html>