From 578d39a3adcac415b729c0c7480b4a83f7af142b Mon Sep 17 00:00:00 2001 From: marvin Date: Sat, 19 Sep 2026 22:24:13 -0400 Subject: [PATCH] Cache certificate inventory and stop refreshPendingProxies from re-triggering a full refresh --- README.md | 2 +- ROADMAP.md | 2 ++ package.json | 2 +- src/public/app.js | 12 +++++++++++- src/public/index.html | 4 ++-- src/server.js | 13 ++++++++++++- 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f0766c7..7407871 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Docker Architectures Caddy - Version + Version

Why Site Gateway · diff --git a/ROADMAP.md b/ROADMAP.md index 5a86f27..ee98c45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -208,3 +208,5 @@ Roughly in priority order: `v0.16.40` moves the Administration → System tab's Version panel Uptime figure onto the same 7-second `/api/system/health` poll driving the hero panel above it, instead of only updating when the page's slower, general dashboard snapshot refetches (on load, or every ~30 seconds while the Dashboard view specifically is active). Found while reviewing the v0.16.39 change: the hero's own Uptime slot was now current to the second, but the separate Version-panel line right below it — the same number, shown twice on the same page — could still be stale by up to half a minute or more. `renderSystemStatus()`'s Uptime is now wrapped in its own `#system-version-uptime` span and updated by a small `updateSystemVersionUptime(health)` helper, called both from the initial render and from the same 7-second timer that already refreshes the hero, so the two Uptime figures on that page can no longer drift apart. + +`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. diff --git a/package.json b/package.json index 78dc748..41879fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.16.40", + "version": "0.16.41", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/app.js b/src/public/app.js index c6bae82..a72ee4b 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -533,12 +533,22 @@ function render() { // --- 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; }); } } +// 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. +// This used to call the full refresh() (all 8 endpoints, including two redundant certificate +// walks via /api/dashboard + /api/certificates), up to 3 times in a row -- meaning a single +// pending proxy could quietly trigger 3 extra full-app refetches over 6 seconds. Since all it +// actually needs is fresh upstream status, it now re-fetches only /api/proxies. async function refreshPendingProxies(ids = []) { const pending = new Set(ids.map(String)); for (const delay of [1000, 2000, 3000]) { if (!pending.size) return; await new Promise(resolve => setTimeout(resolve, delay)); - await refresh(); + try { + state.proxies = await api("/api/proxies"); + render(); + window.renderExtendedViews?.(); + } catch { /* Keep the last-known proxy list if this poll fails; the next delay tries again. */ } for (const proxy of state.proxies) if (pending.has(String(proxy.id)) && proxy.upstream) pending.delete(String(proxy.id)); } } diff --git a/src/public/index.html b/src/public/index.html index 71fe66a..3942f88 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -8,7 +8,7 @@ Site Gateway - + - + diff --git a/src/server.js b/src/server.js index 2bf9982..6520d8e 100644 --- a/src/server.js +++ b/src/server.js @@ -64,6 +64,14 @@ let caddyVersion = "Unknown"; const recentActivity = []; const upstreamHealth = new Map(); const certificateStatusCache = new Map(); +// Certificate inventory is expensive (it walks and parses every certificate file on disk) and is +// recomputed on every call with no memoization. It's called twice per client refresh() cycle -- +// once from /api/dashboard, once from /api/certificates -- and that whole cycle can itself repeat +// several times in a row (see refreshPendingProxies in app.js), so a short time-based cache here +// collapses that duplicate work into a single real disk walk every few seconds. The window is kept +// well under the 7s hero-poll interval so nothing ever appears more than one cycle stale. +const CERTIFICATE_INVENTORY_CACHE_MS = 3000; +let certificateInventoryCache = null; // { at: number, value: object } const loginAttempts = new Map(); const rateLimitBuckets = new Map(); let dockerSocketMounted = false; @@ -882,6 +890,7 @@ function certificateNames(certificate) { } async function certificateInventory() { + if (certificateInventoryCache && Date.now() - certificateInventoryCache.at < CERTIFICATE_INVENTORY_CACHE_MS) return certificateInventoryCache.value; const configured = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))] .filter(item => item.enabled && item.domain && item.tls !== "http"); const configuredDomains = configured.flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain }))); @@ -908,7 +917,9 @@ async function certificateInventory() { }); for (const certificate of certificates) { const previous = certificateStatusCache.get(certificate.domain); if (previous && previous !== certificate.status) recordActivity(`Certificate status changed for ${certificate.domain}: ${previous} → ${certificate.status}.`, certificate.status === "healthy" ? "ok" : "error"); certificateStatusCache.set(certificate.domain, certificate.status); } const latestError = recentActivity.find(item => item.status === "error" && /cert|tls|acme|caddy|gateway/i.test(item.message)) || null; - return { checkedAt: new Date().toISOString(), thresholds: settings.certificateHealth, latestError, summary: { total: certificates.length, healthy: certificates.filter(item => item.status === "healthy").length, within30Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 30 && item.daysRemaining > 0).length, within7Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 7 && item.daysRemaining > 0).length, warning: certificates.filter(item => item.status === "warning").length, critical: certificates.filter(item => item.status === "critical").length, expired: certificates.filter(item => item.status === "expired").length, pending: certificates.filter(item => item.status === "pending").length, mismatch: certificates.filter(item => item.status === "mismatch").length }, certificates }; + const result = { checkedAt: new Date().toISOString(), thresholds: settings.certificateHealth, latestError, summary: { total: certificates.length, healthy: certificates.filter(item => item.status === "healthy").length, within30Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 30 && item.daysRemaining > 0).length, within7Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 7 && item.daysRemaining > 0).length, warning: certificates.filter(item => item.status === "warning").length, critical: certificates.filter(item => item.status === "critical").length, expired: certificates.filter(item => item.status === "expired").length, pending: certificates.filter(item => item.status === "pending").length, mismatch: certificates.filter(item => item.status === "mismatch").length }, certificates }; + certificateInventoryCache = { at: Date.now(), value: result }; + return result; } async function pruneOrphanedCertificates(candidateDomains) {