Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d5842448e | |||
| 40a68b3b96 | |||
| bf524e5835 | |||
| 926296d0d7 | |||
| 578d39a3ad | |||
| 8e6a5f9196 | |||
| cf313f7abf | |||
| a413f53577 | |||
| ecb4b1ad0a | |||
| 39f10f7378 | |||
| 4ff29d4b2a | |||
| af5223dc63 | |||
| bf93c009f0 | |||
| 9b80114e5b | |||
| 813dde1f80 | |||
| 8b48e0db6c | |||
| 992215151d | |||
| b95a20942e | |||
| 16dbbfec4e | |||
| 7e8e604df3 | |||
| 79fd947adb | |||
| 496716d4b2 | |||
| 5ac088025a | |||
| ededa5a76f |
@@ -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.21-62E6A7">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.45-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.
|
||||
@@ -94,6 +94,7 @@ Automatic HTTPS requires valid public DNS and inbound access to port 80 or 443.
|
||||
| `ADMIN_PORT` | `8080` | Dashboard port inside the container |
|
||||
| `SITE_PORT_MIN` / `SITE_PORT_MAX` | `9000` / `9099` | Direct-LAN port range Hosted Sites can bind to |
|
||||
| `DATA_DIR` | `/data` | Persistent state location |
|
||||
| `DATA_DIR_LIMIT_GB` | empty | Optional display-only allowance for the System tab's Disk stat (e.g. a smaller dedicated share); usage/free space still come from the real volume |
|
||||
| `BACKUP_PASSWORD` | empty | Encryption password used only when encrypted scheduled backups are enabled |
|
||||
| `PUID` / `PGID` | `1000` / `1000` | User/group the container writes files as (Unraid: `99`/`100`) |
|
||||
| `ACME_EMAIL` | empty | Optional certificate account email |
|
||||
|
||||
+50
@@ -168,3 +168,53 @@ Roughly in priority order:
|
||||
`v0.16.20` audits role enforcement across the app after a run of Administration changes and fixes three places where the frontend showed a control the backend would actually reject for Standard Users and Viewers: the Dashboard's "Resync now" button (Needs Attention drift tile) and the Certificates page's "Run certificate check" button are now hidden for anyone who isn't an administrator, since both call administrator-only endpoints. The Access List editor's "Allowed groups" section -- previously always rendered with an empty `state.groups`, so a Standard User just saw a false "No groups have been created yet." -- now shows an accurate note pointing to an administrator instead, both when creating a new Access List and editing an existing one. Also corrected the in-app documentation: the Users & Groups role summary previously said Viewer "can inspect everything," which wasn't true -- Administration (System, Users, Groups, Backups, API Access, Logs & Retention, Danger Zone) is completely invisible to Viewer, the same as Standard, not merely read-only. The role summary, the Access Lists doc's Groups field, the Certificates doc's Check now section, and the Dashboard doc's Resync now section all now say plainly which actions are administrator-only.
|
||||
|
||||
`v0.16.21` gives API Access tokens full parity with every other tile type. Tokens now get a real, persistent custom icon -- a new `icon`/`icon_slug` column pair on the `api_tokens` table (added via an idempotent `ALTER TABLE`, safe on existing installs), matching storage functions, and a `tokens` branch in the shared icon-upload/search/URL routes -- plus the same "•••" card menu every other tile has, with Change icon and Revoke token moved into it. While wiring this up, found and fixed a real pre-existing bug: Groups' own "Change icon" menu item has been broken since it shipped, because the frontend code that actually saves an icon never mapped the `groups` kind to anything and silently fell through to the Hosted Sites endpoint, which always 404'd. Also finished the rest of the API Access fix list: the Full access/Read-only counts in the summary bar now only tally active tokens, so they stay consistent with the Active/Revoked split instead of quietly including tokens that can no longer authenticate; a "Hide revoked" toggle sits at the right of that same summary bar for anyone who's revoked enough tokens over time that the tile grid gets cluttered; and the documentation now explains why a revoked token can't be deleted outright -- the record stays for the same accountability reasons the Audit log is never editable.
|
||||
|
||||
`v0.16.22` fixes Docker socket detection for the common case where `/var/run/docker.sock` is correctly bind-mounted but Site Gateway still reports "not detected." Root cause: `detectDockerSocket()` checks that the running process can actually read the socket, but the container drops straight from root to the unprivileged `PUID:PGID` with no supplementary groups, and the socket is typically owned `root:docker` on the host with mode 660 -- so a perfectly correct mount still fails an unprivileged read check with no group membership behind it. `docker-entrypoint.sh` now handles this automatically: while still root, it reads the socket's actual group GID directly off the mount (no hardcoded GID -- it varies by host, Unraid, Debian, Synology, and others all differ), creates a matching local group if one doesn't already exist, adds the app user to it, and hands `su-exec` a username instead of a bare `uid:gid` so supplementary groups actually apply via `initgroups()`. Every step is best-effort and guarded: if anything about the detection or group setup fails, the container starts exactly as it always has, just without Docker integration, the same as if the socket weren't mounted at all. Also documented in the System tab's Environment & Integrations section, including the one thing this can't route around: the check runs once at boot, so a container that already has the mount added still needs an actual restart, not just a reload, to pick it up.
|
||||
|
||||
`v0.16.23` fixes the "Restart application" button on the System tab doing nothing at all after you confirm the restart in its popup: the dialog closes, the button text never changes to "Restarting...", no toast appears, and no restart actually happens -- explaining why the earlier restart-not-logged report showed no trace anywhere (no activity entry, no audit entry, no fresh boot sequence in the container's own console log), because the request never reached the server in the first place. Root cause: `event.currentTarget` is only valid while a DOM event is still being dispatched -- the browser resets it to `null` once dispatch finishes. The Restart handler read `event.currentTarget` *after* `await`-ing the confirmation dialog, by which point the click event had long since finished dispatching, so that line threw against a null reference before ever reaching the `/api/system/restart` call, and the error had nowhere to surface since it happened outside the handler's own try/catch. Resync and Reload were never affected because both of those capture their button reference as their very first line, before any `await`. Fixed by capturing the button reference synchronously at the top of the Restart handler too, matching the other two.
|
||||
|
||||
`v0.16.24` fixes the "Restart application" button never recovering after a successful restart -- following v0.16.23's fix for the button doing nothing at all, a real restart now goes through correctly (the audit log and activity feed both record it as expected), but the button itself was left stuck on "Restarting..." forever, since nothing in the success path ever reset it or reloaded the page. The handler assumed the toast alone was enough and stopped there, unlike the Danger Zone's Factory Reset flow, which already polls for the server coming back online and reloads automatically. Restart now does the same: once the restart request is accepted, it polls `/api/session` once a second for up to 30 seconds and reloads the page as soon as the dashboard answers again (with a status line explaining what it's waiting on), falling back to a reload regardless if that window elapses -- so the button, and the rest of the UI, recover on their own instead of requiring a manual page refresh.
|
||||
|
||||
`v0.16.25` parallelizes every sequential filesystem walk found across the app after noticing the System tab's storage numbers took a while to appear -- the same pattern turned up on the Certificates tab too, and both are fixed the same way. `directorySize()` (the System tab's disk-usage breakdown) and `walkFiles()` (the Certificates tab's search for every issued certificate file) both used to visit one file or subdirectory at a time, `await`-ing each in turn before moving to the next -- on a data directory with any real number of files, that adds up to a lot of small sequential waits. Both now fan out with `Promise.all` and let the filesystem handle everything concurrently, with no change to what they return. The System tab's five-directory breakdown (sites, backups, certificates, logs, database) is now computed in parallel too, instead of one directory at a time. While tracing the Certificates tab's load time, also found and fixed a real duplicate-work bug: the "Run certificate check" button and the downloadable support report were each independently computing the certificate inventory two to three times per request (`dashboardSnapshot()`, the route handler, and `domainReadiness()` each walked and re-parsed every certificate file separately) -- `dashboardSnapshot()` and `domainReadiness()` now both accept an already-computed inventory and reuse it instead of recomputing it, and the two independent halves of a health check (the dashboard snapshot and the domain-readiness check) now run concurrently rather than one after the other. Deliberately left alone: the code paths that read log files (`readAccessLogs`) and start hosted sites/streams on boot, since both are sequential for real reasons -- the log reader stops as soon as it has enough matching entries, so reading files in parallel would do strictly more work for no benefit, and site/stream startup order matters for safe, predictable port binding.
|
||||
|
||||
`v0.16.26` ships three small UI fixes found while going through the System and API Access tabs. First, the Docker socket status tile's helper text was long enough to truncate with "…" inside its `.health-tile` card -- shortened to the single fact that matters there ("Site Gateway reads the Docker socket read-only to list running containers."), dropping the network-scoping detail so it's consistent with the tile's other single-fact entries (BACKUP_PASSWORD, restart policy); the dropped detail already lives in the in-app documentation. Second, the "Pick container" button sat visibly higher than the target field beside it on the Proxy Hosts, Streaming Hosts, and Settings target fields -- root cause was the sitewide `input{margin-top:7px}` label-gap rule still applying to the input after it's wrapped in a flex row alongside the button, giving the two flex children mismatched margin boxes; the same `7px` is now applied to the wrapper instead and zeroed on the nested input, so the row centers cleanly. Third, the API Access tab's summary bar (Active/Revoked/Full access/Read-only counts plus the "Hide revoked" toggle) could render much taller than intended at certain window widths -- its stat groups and the toggle had no protection against shrinking, so at narrower widths the browser would wrap their text internally instead of just running out of room, and a flex container sizes itself to its tallest child. Added `white-space:nowrap` and `flex-shrink:0` to the summary's stat groups and the "Hide revoked" toggle so they hold their line, plus `flex-wrap` on the summary bar itself as a fallback so if the whole row genuinely doesn't fit, complete items wrap to a new line instead of any single item's text breaking mid-phrase.
|
||||
|
||||
`v0.16.27` fixes the real, deeper causes behind two v0.16.26 fixes that turned out to be incomplete -- both confirmed by rendering the actual markup and CSS in a headless browser and measuring the real computed heights before and after, rather than reasoning from the stylesheet alone. The API Access summary bar was still rendering 32px taller than the Users tab's summary bar (84px vs 52px, measured) even after last release's `white-space`/`flex-shrink` fix, because that fix addressed a different problem (text wrapping) than what was actually happening here: the sitewide `label{margin:var(--space-4) 0 0}` rule -- meant to space a stacked field label above its input -- was also landing on the "Hide revoked" toggle, since it's built as a `<label>` too. That gave it a lopsided 16px top margin with no bottom margin, and a flex row sizes itself to its tallest child's full margin box, so the whole bar grew to accommodate it. This is the same bug class the System tab's Docker toggle was already patched for (`.system-integrations .check-control{margin:0}`) -- the API tokens toggle just didn't get the same treatment when it was added. Fixed by zeroing that toggle's margin the same way. (A real, much smaller ~16px difference remains between the two bars, and that part is expected: the API bar contains an actual bordered, padded checkbox control, and Users' doesn't, so its row is naturally a little taller than one built from plain text alone.) Separately, the "Pick container" button was still measurably 2px shorter than its target field (41px vs 39px, measured) even after realigning their margins -- the button and input use different padding values (11px vs the shared 12px `--space-3`), and no amount of margin/alignment fiddling closes a real padding gap. Gave both an explicit `height:44px`, the same fixed control height already used throughout the app for this exact kind of row (dialog inputs, the log host selector, Settings target fields), so they're now pixel-identical rather than approximately matched.
|
||||
|
||||
`v0.16.28` closes the API Access summary bar's remaining height gap against every other tab's summary bar (Users, Groups, Hosted Sites, Proxy Hosts, and so on) -- measured with the same headless-browser approach as v0.16.27: 68px vs 52px before this release, now 53px vs 52px, a difference too small to see and driven only by the checkbox input's own fixed 17px size (every checkbox in the app is 17px; shrinking just this one to save the last pixel would have made it the odd one out). The remaining gap came from the "Hide revoked" toggle's bordered, padded pill styling -- a treatment none of the other tabs' summary bars use, since none of them embed a control inline with their stat counts. Rather than keep splitting the difference, the toggle now sits flush in the bar like the stat counts beside it: no border, no background, no padding, and its label text no longer inherits `.check-control`'s 1.35 line-height (meant for roomier form checkboxes, not a compact inline one). It still reads clearly as an interactive control -- the checkbox itself, its green accent color, and the pointer cursor on hover are untouched -- it just no longer sits inside its own nested box within the already-bordered summary bar.
|
||||
|
||||
`v0.16.29` adds a hero panel to the top of the Administration > System tab -- a single, visually distinct "one-stop shop" for this container's live CPU, memory, swap, disk, and network numbers, plus request throughput, all in one place instead of scattered across the plain status tiles below it. CPU, memory, and swap all read directly from this container's own cgroup v2 files (`cpu.stat`'s `usage_usec`, `memory.current`/`memory.max`, `memory.swap.current`/`memory.swap.max`) rather than host-level figures, on the same reasoning already settled for this feature: Site Gateway is rarely the only thing running on the host, so a host-wide number would be misleading in a dashboard scoped to one container. CPU percent is computed from two samples of the cumulative `usage_usec` counter taken a poll apart, normalized against `cpu.max`'s quota when one is set (or the host's core count when it isn't); memory and swap read straight off their `.current`/`.max` pairs, with swap showing "Off" rather than a stale percentage when the container has none configured. Disk reuses the same `statfs`-on-the-data-volume approach the System tab's storage breakdown already used. Network throughput is new: since `/sys/class/net/*/statistics/{rx_bytes,tx_bytes}` are cumulative counters too, a background sampler reads every non-loopback interface every 5 seconds and keeps a rolling rate in memory, so the hero panel always shows a real, smoothed rate rather than a lifetime total or a jittery two-reads-per-request estimate. Request throughput reuses the exact number already shown on the main Dashboard ("requests in the last minute"), so the two stay in sync without duplicating the underlying query. Each stat degrades independently and visibly rather than silently: a metric with no readable source (cgroup v1 hosts, a sandboxed `/sys/class/net`, and so on) shows a dash and a one-line explanation instead of a wrong number or a blank space, and CPU/memory/swap/disk fills turn amber past 75% and red past 90%, matching the color language already used elsewhere in the app for degraded/warning states.
|
||||
|
||||
`v0.16.30` fixes two numbers on the System tab's new hero panel (added in v0.16.29) that were technically correct but meant the wrong thing. CPU percent was always computed against either a real Docker `--cpus` quota or, absent one, the *host's total core count* -- so pinning the container to 2 specific cores (`--cpuset-cpus`, Unraid's CPU pinning field) didn't change the denominator at all, since pinning caps which cores can run without capping how much of them can be used, and `cpu.max` stays `max` either way. CPU now checks `cpuset.cpus.effective` (the actual pinned core list, correctly counting ranges like `0-1,4`) whenever there's no real quota, and the hero panel's detail line now says which denominator applies -- "Of N allocated CPUs" for a real `--cpus` quota, "Of N pinned cores" for cpuset pinning with no quota, or "Of host's N cores -- no limit set" when neither is configured -- instead of always claiming "Of this container's CPU quota" even when there wasn't one. Swap had a similar honesty problem: without an explicit `--memory-swap` limit, `memory.swap.max` reads `max` (unbounded, shared with the host's swap) rather than "0," but the panel showed a bare "0 B" that read like a real, enforced cap. It now only shows a percentage when a real swap limit exists; otherwise it shows the actual bytes in use with "Unlimited -- shares host swap" instead of implying a limit that was never set.
|
||||
|
||||
`v0.16.31` adds the two remaining items from the System tab hero panel's fix list. First, the panel now keeps itself current while you're actually looking at it: a lightweight timer polls `/api/system/health` directly every 7 seconds whenever the System tab is the visible admin panel, separate from the app's full `refresh()` (which also refetches sites, proxies, certificates, and everything else) so it stays cheap on a fast interval, and it's a no-op the moment you navigate away rather than continuing to poll in the background. Previously the hero panel only updated on initial page load or whenever *anything else* in the app happened to trigger a `refresh()` -- sitting on the tab watching it did nothing. Second, a new `DATA_DIR_LIMIT_GB` environment variable lets an operator tell the Disk stat what's actually assigned to this deployment -- a dedicated share or zvol smaller than the whole host volume, for instance -- instead of always showing usage against the full underlying filesystem size. This is necessarily display-only, since Docker has no real per-container disk-space quota the way it does for CPU (`cpu.max`) or memory (`memory.max`); actual usage and free space still come straight from `statfs` on the real volume, only the percentage's denominator and the "used of X assigned" label change. Set past 100%, the stat turns red rather than silently capping, since exceeding an assigned allowance is a real, meaningful warning rather than a display bug.
|
||||
|
||||
`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.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.
|
||||
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
+43
-1
@@ -29,9 +29,51 @@ fi
|
||||
export XDG_DATA_HOME="${DATA_DIR:-/data}/certificates/managed"
|
||||
export XDG_CONFIG_HOME="${DATA_DIR:-/data}/caddy/config"
|
||||
|
||||
# --- Docker socket group access -------------------------------------------------------------
|
||||
# A bind-mounted /var/run/docker.sock is typically owned root:docker on the host with mode
|
||||
# 0660 -- readable only by root or members of that group. The app drops straight to an
|
||||
# unprivileged PUID:PGID with no supplementary groups, so even a correctly mounted socket looks
|
||||
# "not detected" to it. The Docker group's GID varies host to host (Unraid, Debian, Synology,
|
||||
# etc. all differ), so rather than hardcode one, read it directly off the mounted socket while
|
||||
# still root, make sure a local group with that GID exists and the app user is a member of it,
|
||||
# then hand su-exec a username instead of a bare uid:gid so it picks up supplementary groups via
|
||||
# initgroups() -- the uid:gid form only ever sets the one primary group. Every step here is
|
||||
# best-effort: if anything fails, app_exec_target stays the original "$app_uid:$app_gid" and the
|
||||
# app starts exactly as it always has, just without Docker integration -- same as an unmounted
|
||||
# socket, never worse.
|
||||
app_exec_target="$app_uid:$app_gid"
|
||||
docker_socket="/var/run/docker.sock"
|
||||
if [ -S "$docker_socket" ]; then
|
||||
docker_gid="$(stat -c '%g' "$docker_socket" 2>/dev/null || true)"
|
||||
if [ -n "$docker_gid" ] && [ "$docker_gid" != "$app_gid" ]; then
|
||||
docker_group_name="$(getent group "$docker_gid" 2>/dev/null | cut -d: -f1 || true)"
|
||||
if [ -z "$docker_group_name" ]; then
|
||||
addgroup -g "$docker_gid" sgdockersock 2>/dev/null || true
|
||||
docker_group_name="$(getent group "$docker_gid" 2>/dev/null | cut -d: -f1 || true)"
|
||||
fi
|
||||
if [ -n "$docker_group_name" ]; then
|
||||
app_group_name="$(getent group "$app_gid" 2>/dev/null | cut -d: -f1 || true)"
|
||||
if [ -z "$app_group_name" ]; then
|
||||
addgroup -g "$app_gid" sgapp 2>/dev/null || true
|
||||
app_group_name="$(getent group "$app_gid" 2>/dev/null | cut -d: -f1 || true)"
|
||||
fi
|
||||
if [ -n "$app_group_name" ] && ! getent passwd "$app_uid" >/dev/null 2>&1; then
|
||||
adduser -D -H -u "$app_uid" -G "$app_group_name" sgapp 2>/dev/null || true
|
||||
fi
|
||||
app_user_name="$(getent passwd "$app_uid" 2>/dev/null | cut -d: -f1 || true)"
|
||||
if [ -n "$app_user_name" ]; then
|
||||
addgroup "$app_user_name" "$docker_group_name" 2>/dev/null || true
|
||||
if id -nG "$app_user_name" 2>/dev/null | grep -qw "$docker_group_name"; then
|
||||
app_exec_target="$app_user_name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
su-exec "$app_uid:$app_gid" caddy run --config "$caddyfile" --adapter caddyfile &
|
||||
caddy_pid=$!
|
||||
su-exec "$app_uid:$app_gid" "$@" &
|
||||
su-exec "$app_exec_target" "$@" &
|
||||
app_pid=$!
|
||||
|
||||
shutdown() {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.16.21",
|
||||
"version": "0.16.45",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
|
||||
+117
-21
@@ -123,7 +123,7 @@ document.addEventListener("submit", async event => {
|
||||
try {
|
||||
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 }); }
|
||||
$("#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; }
|
||||
finally { button.disabled = false; }
|
||||
@@ -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,17 +213,10 @@ 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);
|
||||
$("#system-memory").textContent = formatBytes(data.system.memoryBytes);
|
||||
$("#system-data").textContent = formatBytes(data.system.dataBytes);
|
||||
$("#system-disk").textContent = formatBytes(data.system.diskFreeBytes);
|
||||
$("#system-disk").title = `${formatBytes(data.system.diskFreeBytes)} available of ${formatBytes(data.system.diskTotalBytes)} on the /data volume`;
|
||||
$("#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";
|
||||
// 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/
|
||||
// swap/disk/network from /api/system/health instead of this endpoint's coarser numbers.
|
||||
$("#attention-panel").classList.toggle("is-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"
|
||||
@@ -234,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) -----------------------------------
|
||||
@@ -502,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");
|
||||
$("#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) {
|
||||
$("#page-title").textContent = "Dashboard";
|
||||
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
||||
@@ -541,13 +532,76 @@ 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; }); } }
|
||||
// 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() --
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -556,6 +610,26 @@ 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/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, { 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. */ }
|
||||
}
|
||||
|
||||
// --- Boot: session check, initial routing, periodic health/update checks -------------------
|
||||
function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); }
|
||||
@@ -573,6 +647,13 @@ 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();
|
||||
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);
|
||||
// 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. 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() {
|
||||
@@ -617,6 +698,21 @@ $("#dashboard-view").addEventListener("click", event => { const target = event.t
|
||||
|
||||
// --- Logs & Performance filter controls -----------------------------------------------------
|
||||
$("#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")));
|
||||
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||
@@ -674,8 +770,8 @@ document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("c
|
||||
|
||||
// --- 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 refresh(); 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"; } });
|
||||
$("#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"; } });
|
||||
|
||||
|
||||
// --- Health-check field visibility polish for the create forms ------------------------------
|
||||
@@ -709,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;
|
||||
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();
|
||||
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 === "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(); }
|
||||
@@ -753,7 +849,7 @@ window.openCaddyConfig = openCaddyConfig;
|
||||
|
||||
|
||||
// --- 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; });
|
||||
|
||||
|
||||
|
||||
+128
-7
@@ -318,8 +318,20 @@ function normalizeRetentionLayout() { const form = document.querySelector('[data
|
||||
normalizeRetentionLayout();
|
||||
function cleanRetentionLabels() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form) return; const descriptions = { 'Access logs':'High-volume request records.', 'Gateway activity':'Operational and configuration events.', 'Audit logs':'Administrative accountability records.', 'Certificate events':'Certificate issuance and health changes.', 'Security events':'Authentication and security-related events.' }; [...form.querySelectorAll('label:not(.check-control)')].forEach(field => { const text = field.firstChild; const name = text?.textContent?.trim().replace(/ \(days\)$/, ''); if (!text || !descriptions[name]) return; if (!text.textContent.includes('(days)')) text.textContent = `${name} (days)`; let help = field.querySelector('small'); if (!help) { help = document.createElement('small'); field.append(help); } help.textContent = descriptions[name]; }); }
|
||||
setTimeout(() => { cleanRetentionLabels(); normalizeRetentionLayout(); }, 0); setInterval(() => { cleanRetentionLabels(); normalizeRetentionLayout(); }, 300);
|
||||
function renderRetentionRunStatus() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); const form = panel?.querySelector('.retention-form'); if (!panel || !form) return; const value = state.settings?.logsRetention?.lastRunAt ? state.settings.logsRetention : null; let status = panel.querySelector('.retention-run-status'); if (!status) { status = document.createElement('div'); status.className = 'retention-run-status muted'; const actions = form.querySelector('.dialog-actions'); if (actions) actions.before(status); else form.append(status); } status.textContent = value ? `Last run: ${value.lastRunMode || 'manual'} · ${new Date(value.lastRunAt).toLocaleString()} · Snapshot: ${value.lastRunSnapshot || 'available'}` : 'No pruning run yet.'; }
|
||||
async function renderRetentionPreview() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let preview = panel.querySelector('.retention-preview'); if (!preview) { preview = document.createElement('div'); preview.className = 'retention-preview muted'; const form = panel.querySelector('.retention-form'); const status = panel.querySelector('.retention-run-status'); (status || form)?.before(preview); } try { const data = await api('/api/logs/prune/preview'); const counts = data.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + Number(value || 0), 0); preview.textContent = data.enabled ? `Eligible to prune: ${total} records · Access ${counts.access || 0} · Activity ${counts.activity || 0} · Certificates ${counts.certificate || 0} · Security ${counts.security || 0} · Audit ${counts.audit || 0}` : 'Pruning is disabled. Enable automatic pruning to preview eligible records.'; } catch { preview.textContent = 'Prune preview unavailable.'; } }
|
||||
function renderRetentionRunStatus() { if (!state.user || !isRetentionPanelVisible()) return; const panel = document.querySelector('[data-admin-panel="retention"]'); const form = panel?.querySelector('.retention-form'); if (!panel || !form) return; const value = state.settings?.logsRetention?.lastRunAt ? state.settings.logsRetention : null; let status = panel.querySelector('.retention-run-status'); if (!status) { status = document.createElement('div'); status.className = 'retention-run-status muted'; const actions = form.querySelector('.dialog-actions'); if (actions) actions.before(status); else form.append(status); } status.textContent = value ? `Last run: ${value.lastRunMode || 'manual'} · ${new Date(value.lastRunAt).toLocaleString()} · Snapshot: ${value.lastRunSnapshot || 'available'}` : 'No pruning run yet.'; }
|
||||
// The retention panel's <section> lives in index.html from page load (just CSS-hidden until its
|
||||
// admin tab is selected), so "does the panel element exist" was never a real visibility check --
|
||||
// it's always true, on every page of the app. That let this run forever, everywhere, not just on
|
||||
// Administration > Logs & retention. Combined with no guard against overlapping calls, a single
|
||||
// slow response (previewPruneEvents() runs several SQLite COUNT queries, synchronously, blocking
|
||||
// the whole server while they run) let requests pile up faster than the server could drain them --
|
||||
// confirmed via a live Network-tab capture showing this same request queued for 15+ seconds while
|
||||
// unrelated requests (dashboard, health, security, storage) sat stuck at nearly the same time,
|
||||
// waiting behind it. isRetentionPanelVisible() below checks the panel is both present AND not
|
||||
// hidden, and retentionPreviewInFlight prevents a new poll from starting until the last one lands.
|
||||
function isRetentionPanelVisible() { const panel = document.querySelector('[data-admin-panel="retention"]'); return Boolean(panel && !panel.classList.contains("hidden")); }
|
||||
let retentionPreviewInFlight = false;
|
||||
async function renderRetentionPreview() { if (!state.user || !isRetentionPanelVisible() || retentionPreviewInFlight) return; const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let preview = panel.querySelector('.retention-preview'); if (!preview) { preview = document.createElement('div'); preview.className = 'retention-preview muted'; const form = panel.querySelector('.retention-form'); const status = panel.querySelector('.retention-run-status'); (status || form)?.before(preview); } retentionPreviewInFlight = true; try { const data = await api('/api/logs/prune/preview'); const counts = data.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + Number(value || 0), 0); preview.textContent = data.enabled ? `Eligible to prune: ${total} records · Access ${counts.access || 0} · Activity ${counts.activity || 0} · Certificates ${counts.certificate || 0} · Security ${counts.security || 0} · Audit ${counts.audit || 0}` : 'Pruning is disabled. Enable automatic pruning to preview eligible records.'; } catch { preview.textContent = 'Prune preview unavailable.'; } finally { retentionPreviewInFlight = false; } }
|
||||
async function renderRetentionHistory() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let history = panel.querySelector('.retention-history'); if (!history) { history = document.createElement('div'); history.className = 'retention-history'; (panel.querySelector('.retention-run-status') || panel.querySelector('.retention-form'))?.after(history); } try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(0, 50); history.innerHTML = `<div class="retention-history-heading"><strong>Prune history</strong><span>${rows.length} runs</span></div>` + (rows.length ? `<div class="retention-history-list">${rows.map(item => `<div class="retention-history-row"><span class="status-dot ${item.status === 'error' ? 'disabled' : 'running'}"></span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}</small></span></div>`).join('')}</div>` : '<p class="muted">No pruning runs recorded yet.</p>'); } catch { history.innerHTML = '<p class="muted">Prune history unavailable.</p>'; } }
|
||||
function ensureRetentionLoadMore() { const history = document.querySelector('.retention-history'); if (!history || history.querySelector('[data-retention-load-more]')) return; const button = document.createElement('button'); button.className = 'text-button retention-load-more'; button.dataset.retentionLoadMore = 'true'; button.textContent = 'Load more'; history.append(button); }
|
||||
document.addEventListener('click', async event => { const button = event.target.closest('[data-retention-load-more]'); if (!button) return; try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(50); const list = button.parentElement.querySelector('.retention-history-list'); rows.forEach(item => { const row = document.createElement('div'); row.className = 'retention-history-row'; row.innerHTML = `<span class="status-dot ${item.status === 'error' ? 'disabled' : 'running'}"></span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}</small></span>`; list?.append(row); }); button.remove(); } catch { button.textContent = 'History unavailable'; } });
|
||||
@@ -514,7 +526,7 @@ function renderDockerPanel() {
|
||||
toggle.disabled = !socketMounted;
|
||||
tile.querySelector(".status-dot").className = `status-dot ${socketMounted ? "running" : "idle"}`;
|
||||
tile.querySelector("#docker-integration-help").textContent = socketMounted
|
||||
? "Site Gateway reads the Docker socket read-only to list running containers, and only offers containers that share a Docker network with it."
|
||||
? "Site Gateway reads the Docker socket read-only to list running containers."
|
||||
: "Docker socket not detected — mount /var/run/docker.sock into this container to enable container selection.";
|
||||
control.classList.toggle("is-disabled", !socketMounted);
|
||||
}
|
||||
@@ -571,6 +583,64 @@ document.addEventListener("click", async event => {
|
||||
});
|
||||
|
||||
|
||||
// Formats a byte rate as e.g. "1.2 MB/s"; reuses formatBytes and just appends the rate suffix.
|
||||
// formatBytes() only rounds once a value crosses into KB -- below that it echoes the raw
|
||||
// number verbatim, which is fine for the file sizes it’s normally fed (always whole integers)
|
||||
// but not for a computed rate, so round to a whole byte first.
|
||||
function formatRate(bytesPerSecond) { return `${formatBytes(Math.round(bytesPerSecond))}/s`; }
|
||||
// Builds one hero panel's stat markup for a given prefix ("system-hero" on the Administration >
|
||||
// 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 (fillEl) { fillEl.style.width = `${Math.max(0, Math.min(100, percent ?? 0))}%`; fillEl.className = `system-hero-fill${tone ? ` ${tone}` : ""}`; }
|
||||
if (detailEl) detailEl.textContent = detail || "";
|
||||
}
|
||||
// Populates a hero panel's CPU/memory/swap/disk/network stats (shared by both the System tab and
|
||||
// the Dashboard) from /api/system/health. Each stat degrades gracefully to a dash when its source
|
||||
// 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, { 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" : "";
|
||||
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`;
|
||||
setHeroStat(prefix, "cpu", { value: `${health.cpu.percent.toFixed(1)}%`, percent: health.cpu.percent, tone: tone(health.cpu.percent), detail: quotaLabel });
|
||||
}
|
||||
else setHeroStat(prefix, "cpu", { value: "\u2014", detail: "cgroup CPU stats unavailable" });
|
||||
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(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
|
||||
// (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.
|
||||
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(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(prefix, "swap", { value: formatBytes(health.swap.usedBytes), percent: 0, detail: "Unlimited \u2014 shares host swap" });
|
||||
else setHeroStat(prefix, "swap", { value: "\u2014", detail: "cgroup swap stats unavailable" });
|
||||
if (health.disk) {
|
||||
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`;
|
||||
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(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 (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() {
|
||||
if (state.user?.role !== "administrator") return;
|
||||
@@ -584,6 +654,7 @@ function renderSystemPanel() {
|
||||
panel.dataset.ready = "1";
|
||||
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="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>`,
|
||||
'<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">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="health-grid"></div></div>',
|
||||
@@ -604,14 +675,47 @@ function renderSystemPanel() {
|
||||
finally { button.disabled = false; button.textContent = original; }
|
||||
});
|
||||
panel.querySelector("#system-restart").addEventListener("click", async event => {
|
||||
const button = event.currentTarget;
|
||||
if (!await themedConfirm("Restart Site Gateway?", "The application will stop and restart. This takes a few seconds and briefly interrupts hosted sites and the dashboard.", "Restart")) return;
|
||||
const button = event.currentTarget; button.disabled = true; button.textContent = "Restarting\u2026";
|
||||
try { await api("/api/system/restart", { method: "POST" }); toast("Restarting \u2014 this dashboard will be unavailable briefly."); }
|
||||
button.disabled = true; button.textContent = "Restarting\u2026";
|
||||
const restartStatus = document.querySelector("#system-restart-status");
|
||||
try {
|
||||
await api("/api/system/restart", { method: "POST" });
|
||||
toast("Restarting \u2014 this dashboard will be unavailable briefly.");
|
||||
button.textContent = "Waiting for Site Gateway\u2026";
|
||||
if (restartStatus) restartStatus.textContent = "Reconnecting once Site Gateway comes back online\u2026";
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
try {
|
||||
const response = await fetch("/api/session", { cache: "no-store" });
|
||||
if (response.ok) { location.reload(); return; }
|
||||
} catch { /* Still restarting -- the dashboard is briefly unreachable while the container comes back up. */ }
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
catch (error) { toast(error.message, "error"); button.disabled = false; button.textContent = "Restart application"; }
|
||||
});
|
||||
// Keep the hero panel's live numbers current while the System tab is actually visible --
|
||||
// a lightweight direct poll of /api/system/health, not a full refresh() (which also
|
||||
// refetches sites/proxies/certificates/etc.), so it stays cheap even on a fast interval.
|
||||
// Stops itself from doing any work (skips the fetch) once the tab isn't in view, mirroring
|
||||
// the guard the Dashboard's own health timer already uses for the same reason.
|
||||
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); updateSystemVersionUptime(health); }).catch(() => {});
|
||||
}, 7000);
|
||||
}
|
||||
renderSystemStatus(panel);
|
||||
}
|
||||
// Updates just the Version panel's Uptime figure from a fresh /api/system/health payload --
|
||||
// kept separate from the rest of renderSystemStatus() so it can be called on the fast 7s hero
|
||||
// poll without re-rendering (or re-fetching) everything else in that panel.
|
||||
function updateSystemVersionUptime(health) {
|
||||
const el = document.querySelector("#system-version-uptime");
|
||||
if (!el) return;
|
||||
el.textContent = Number.isFinite(health?.uptimeSeconds) ? formatDuration(health.uptimeSeconds) : "Unavailable";
|
||||
}
|
||||
async function renderSystemStatus(panel) {
|
||||
panel = panel || document.querySelector('[data-admin-panel="system"]');
|
||||
if (!panel) return;
|
||||
@@ -630,13 +734,30 @@ 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>`;
|
||||
}
|
||||
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> · Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
|
||||
if (version) {
|
||||
// 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. Uptime is the one exception: it's wrapped in
|
||||
// its own #system-version-uptime span and kept current by updateSystemVersionUptime(), called
|
||||
// from the same 7-second /api/system/health poll that drives the hero panel above, instead of
|
||||
// only refreshing on the slower ~30s dashboard snapshot like the rest of this block.
|
||||
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: <span id="system-version-uptime">${uptime}</span> \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> · Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
|
||||
}
|
||||
try {
|
||||
const [sec, store, policy] = await Promise.all([
|
||||
const [sec, store, policy, health] = await Promise.all([
|
||||
api("/api/system/security"),
|
||||
api("/api/system/storage"),
|
||||
api("/api/system/restart-policy"),
|
||||
api("/api/system/health").catch(() => null),
|
||||
]);
|
||||
renderHeroPanel("system-hero", health);
|
||||
if (health) updateSystemVersionUptime(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." },
|
||||
|
||||
+12
-14
File diff suppressed because one or more lines are too long
+29
-5
@@ -44,8 +44,8 @@ h2{letter-spacing:-.025em}
|
||||
.wide{width:100%}
|
||||
|
||||
/* Top summary bar & status dot indicator */
|
||||
.summary{display:flex;align-items:center;gap:28px;margin:var(--space-7) 0 28px;padding:17px 20px;background:rgba(var(--panel-rgb),.75);border:1px solid var(--line);border-radius:var(--radius-2xl)}
|
||||
.summary>div{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:.88rem}
|
||||
.summary{display:flex;align-items:center;flex-wrap:wrap;gap:28px;margin:var(--space-7) 0 28px;padding:17px 20px;background:rgba(var(--panel-rgb),.75);border:1px solid var(--line);border-radius:var(--radius-2xl)}
|
||||
.summary>div{display:flex;align-items:center;flex-shrink:0;gap:9px;color:var(--muted);font-size:.88rem;white-space:nowrap}
|
||||
.summary strong{color:var(--text)}
|
||||
.port-note{margin-left:auto}
|
||||
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--status-dot-idle)}
|
||||
@@ -950,8 +950,9 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.copy-icon{width:16px;height:16px;display:block}
|
||||
|
||||
/* Docker container picker */
|
||||
.target-with-picker{display:flex;gap:var(--space-2);align-items:center}
|
||||
.target-with-picker input{flex:1;min-width:0}
|
||||
.target-with-picker{display:flex;gap:var(--space-2);align-items:center;margin-top:7px}
|
||||
.target-with-picker input{flex:1;min-width:0;margin-top:0;height:44px;box-sizing:border-box}
|
||||
.target-with-picker .container-picker-trigger{height:44px;box-sizing:border-box}
|
||||
.container-picker-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:46vh;overflow:auto}
|
||||
.container-choice{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;padding:var(--space-3) 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.22);color:var(--text);text-align:left;cursor:pointer}
|
||||
.container-choice:hover{border-color:var(--border-hover)}
|
||||
@@ -963,7 +964,8 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.api-token-card{min-height:0}
|
||||
.api-token-card.revoked{opacity:.6}
|
||||
.api-token-card .address{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
.api-token-hide-revoked{margin-left:auto;padding:6px 12px;font-size:.85rem}
|
||||
.api-token-hide-revoked{margin:0 0 0 auto;padding:0;border:0;background:transparent;font-size:.85rem;flex-shrink:0;white-space:nowrap}
|
||||
.api-token-hide-revoked span{line-height:normal}
|
||||
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
|
||||
|
||||
/* Backup history timeline */
|
||||
@@ -975,3 +977,25 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.check-control.is-disabled{opacity:.55}
|
||||
.check-control.is-disabled span{color:var(--muted)}
|
||||
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}
|
||||
|
||||
/* System tab hero: one-stop CPU/memory/swap/disk/network/throughput readout, styled like the
|
||||
decorative .metric-card gradient tiles rather than the plain .health-tile rows below it. */
|
||||
.system-hero{margin:0 0 18px;padding:22px;border:1px solid var(--line);border-radius:var(--radius-lg);background:linear-gradient(145deg,rgba(var(--panel2-rgb),.95),rgba(var(--card-shade-rgb),.95));box-shadow:0 14px 40px rgba(var(--black-rgb),.14)}
|
||||
.system-hero-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:var(--space-4)}
|
||||
.system-hero-stat{min-width:0;display:flex;flex-direction:column;gap:6px}
|
||||
.system-hero-label{color:var(--muted);font-size:var(--font-size-sm);font-weight:650;text-transform:uppercase;letter-spacing:.04em}
|
||||
.system-hero-value{font-size:1.5rem;font-weight:800;line-height:1.1}
|
||||
.system-hero-bar{height:6px;border-radius:var(--radius-full);background:rgba(var(--bg-rgb),.4);overflow:hidden}
|
||||
.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.critical{background:var(--danger)}
|
||||
.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}
|
||||
@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))}}
|
||||
/* 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))}}
|
||||
|
||||
+248
-34
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import dgram from "node:dgram";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -63,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;
|
||||
@@ -83,14 +92,149 @@ function recordActivity(message, status = "ok") {
|
||||
}
|
||||
|
||||
async function directorySize(directory) {
|
||||
let total = 0;
|
||||
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
|
||||
for (const entry of entries) {
|
||||
const sizes = await Promise.all(entries.map(async entry => {
|
||||
const itemPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) total += await directorySize(itemPath);
|
||||
else if (entry.isFile()) total += (await fsp.stat(itemPath)).size;
|
||||
}
|
||||
return total;
|
||||
if (entry.isDirectory()) return directorySize(itemPath);
|
||||
if (entry.isFile()) return (await fsp.stat(itemPath)).size;
|
||||
return 0;
|
||||
}));
|
||||
return sizes.reduce((sum, size) => sum + size, 0);
|
||||
}
|
||||
|
||||
// --- System health: cgroup v2 CPU/memory/swap sampling + network throughput -------------------
|
||||
// All readings are container-scoped (cgroup v2), not host-wide, because Site Gateway usually
|
||||
// isn't the only thing running on the host and host-wide numbers would be misleading in a
|
||||
// per-container dashboard. Falls back to host-level approximations (with a flag the UI can use
|
||||
// to disclaim them) when cgroup v2 files aren't readable -- e.g. cgroup v1 hosts, or a container
|
||||
// runtime that doesn't expose them.
|
||||
const CGROUP_ROOT = "/sys/fs/cgroup";
|
||||
async function readCgroupFile(name) {
|
||||
try { return (await fsp.readFile(path.join(CGROUP_ROOT, name), "utf8")).trim(); } catch { return null; }
|
||||
}
|
||||
let lastCpuSample = null; // { usageMicros, atMs } -- usage_usec is cumulative, so CPU% needs a delta between two samples.
|
||||
// cpu.max sets a real CFS quota (from Docker's --cpus flag); cpuset.cpus.effective is the pinned
|
||||
// core *list* (from --cpuset-cpus / Unraid's CPU pinning), which caps which cores can run but not
|
||||
// how much of them can be used -- pinning alone leaves cpu.max at "max". Percent needs a real
|
||||
// denominator either way, and which one applies (and thus what the number means) has to be
|
||||
// reported back to the UI so the label doesn't lie about what's being measured.
|
||||
async function cgroupCpuQuota() {
|
||||
const max = await readCgroupFile("cpu.max");
|
||||
if (max) { const [quota, period] = max.split(/\s+/); if (quota !== "max") { const q = Number(quota), p = Number(period); if (q > 0 && p > 0) return { cpus: q / p, source: "quota" }; } }
|
||||
const pinned = await readCgroupFile("cpuset.cpus.effective");
|
||||
if (pinned) { const count = expandCpuList(pinned); if (count > 0) return { cpus: count, source: "pinned" }; }
|
||||
return { cpus: os.cpus().length || 1, source: "host" };
|
||||
}
|
||||
// cpuset.cpus.effective is a comma-separated list of cores and ranges, e.g. "0-1,4" -- count how
|
||||
// many individual CPUs that covers rather than assuming a single contiguous range.
|
||||
function expandCpuList(list) {
|
||||
return list.split(",").reduce((total, part) => {
|
||||
const range = part.trim().match(/^(\d+)(?:-(\d+))?$/);
|
||||
if (!range) return total;
|
||||
const start = Number(range[1]), end = range[2] !== undefined ? Number(range[2]) : start;
|
||||
return total + Math.max(0, end - start + 1);
|
||||
}, 0);
|
||||
}
|
||||
async function cgroupCpuPercent() {
|
||||
const stat = await readCgroupFile("cpu.stat");
|
||||
if (!stat) return null;
|
||||
const match = stat.match(/^usage_usec (\d+)/m);
|
||||
if (!match) return null;
|
||||
const usageMicros = Number(match[1]), atMs = Date.now();
|
||||
const previous = lastCpuSample;
|
||||
lastCpuSample = { usageMicros, atMs };
|
||||
if (!previous) return null; // First call has nothing to diff against -- the next poll will have a real number.
|
||||
const elapsedMicros = (atMs - previous.atMs) * 1000;
|
||||
if (elapsedMicros <= 0) return null;
|
||||
const quota = await cgroupCpuQuota();
|
||||
const percent = ((usageMicros - previous.usageMicros) / elapsedMicros) / quota.cpus * 100;
|
||||
return { percent: Math.max(0, Math.min(100, percent)), quotaCpus: quota.cpus, quotaSource: quota.source };
|
||||
}
|
||||
async function cgroupMemory() {
|
||||
const current = await readCgroupFile("memory.current");
|
||||
if (current === null) return null;
|
||||
const maxRaw = await readCgroupFile("memory.max");
|
||||
const totalBytes = os.totalmem();
|
||||
const limitBytes = maxRaw && maxRaw !== "max" ? Number(maxRaw) : totalBytes;
|
||||
const usedBytes = Number(current);
|
||||
return { usedBytes, limitBytes, percent: limitBytes > 0 ? (usedBytes / limitBytes) * 100 : null };
|
||||
}
|
||||
async function cgroupSwap() {
|
||||
const current = await readCgroupFile("memory.swap.current");
|
||||
if (current === null) return null;
|
||||
const maxRaw = await readCgroupFile("memory.swap.max");
|
||||
const usedBytes = Number(current);
|
||||
if (maxRaw === "0") return { usedBytes: 0, limitBytes: 0, percent: null, configured: false };
|
||||
const limitBytes = maxRaw && maxRaw !== "max" ? Number(maxRaw) : null;
|
||||
return { usedBytes, limitBytes, percent: limitBytes ? (usedBytes / limitBytes) * 100 : null, configured: true };
|
||||
}
|
||||
// Network counters are cumulative since the interface came up, so throughput needs a delta
|
||||
// between two samples too -- sampled on a fixed interval in the background (rather than on
|
||||
// each request) so the rate stays smooth regardless of how often the dashboard polls.
|
||||
let lastNetworkSample = null; // { rxBytes, txBytes, atMs }
|
||||
let networkRate = null; // { rxBytesPerSec, txBytesPerSec }
|
||||
async function sampleNetworkInterfaces() {
|
||||
try {
|
||||
const names = (await fsp.readdir("/sys/class/net")).filter(name => name !== "lo");
|
||||
const totals = await Promise.all(names.map(async name => {
|
||||
const [rx, tx] = await Promise.all([
|
||||
fsp.readFile(`/sys/class/net/${name}/statistics/rx_bytes`, "utf8").catch(() => "0"),
|
||||
fsp.readFile(`/sys/class/net/${name}/statistics/tx_bytes`, "utf8").catch(() => "0"),
|
||||
]);
|
||||
return { rx: Number(rx.trim()) || 0, tx: Number(tx.trim()) || 0 };
|
||||
}));
|
||||
const rxBytes = totals.reduce((sum, value) => sum + value.rx, 0), txBytes = totals.reduce((sum, value) => sum + value.tx, 0), atMs = Date.now();
|
||||
if (lastNetworkSample) {
|
||||
const elapsedSeconds = (atMs - lastNetworkSample.atMs) / 1000;
|
||||
if (elapsedSeconds > 0) networkRate = { rxBytesPerSec: Math.max(0, (rxBytes - lastNetworkSample.rxBytes) / elapsedSeconds), txBytesPerSec: Math.max(0, (txBytes - lastNetworkSample.txBytes) / elapsedSeconds) };
|
||||
}
|
||||
lastNetworkSample = { rxBytes, txBytes, atMs };
|
||||
} catch { /* No readable network interfaces (e.g. host networking with restricted /sys) -- the hero panel just omits the network stat. */ }
|
||||
}
|
||||
setInterval(sampleNetworkInterfaces, 5000).unref();
|
||||
sampleNetworkInterfaces();
|
||||
// One combined snapshot for the System tab's hero panel -- CPU/memory/swap/network are all
|
||||
// container-scoped (cgroup v2 + this container's network namespace); disk reuses the same
|
||||
// statfs-on-the-data-volume approach as /api/system/storage.
|
||||
async function systemHealthSnapshot() {
|
||||
const assignedLimitGb = numberEnv("DATA_DIR_LIMIT_GB", null);
|
||||
const assignedLimitBytes = assignedLimitGb && assignedLimitGb > 0 ? assignedLimitGb * 1024 ** 3 : null;
|
||||
const [cpu, memory, swap, disk, appUsedBytes] = await Promise.all([
|
||||
cgroupCpuPercent(),
|
||||
cgroupMemory(),
|
||||
cgroupSwap(),
|
||||
fsp.statfs(dataDir).catch(() => null),
|
||||
// Only walk /data (the same directorySize() the storage breakdown below already uses) when
|
||||
// an assigned limit is actually configured -- it's the one case that needs it, and the walk
|
||||
// isn't free, so skip it when the panel is just going to show whole-volume stats anyway.
|
||||
assignedLimitBytes !== null ? directorySize(dataDir) : Promise.resolve(null),
|
||||
]);
|
||||
return {
|
||||
cpu,
|
||||
memory,
|
||||
swap,
|
||||
disk: disk ? (() => {
|
||||
const totalBytes = disk.blocks * disk.bsize, freeBytes = disk.bfree * disk.bsize, availableBytes = disk.bavail * disk.bsize, volumeUsedBytes = totalBytes - freeBytes;
|
||||
// DATA_DIR_LIMIT_GB lets an operator tell the hero panel what's actually assigned to this
|
||||
// deployment (e.g. a dedicated share/zvol sized smaller than the whole host volume), since
|
||||
// Docker has no real per-container disk-space quota to read the way it does for CPU/memory.
|
||||
// Purely a display denominator -- it doesn't enforce anything -- so usage over 100% is a
|
||||
// real, meaningful warning rather than a bug: it means actual usage has exceeded what was assigned.
|
||||
// Critically, comparing against an assigned allowance has to use Site Gateway's own actual
|
||||
// footprint (appUsedBytes, a real walk of /data), not the whole filesystem's used space --
|
||||
// statfs reports usage for the entire volume behind /data, which on a shared array or pool
|
||||
// includes everything else living on that mount, not just what this app has written.
|
||||
const usedBytes = assignedLimitBytes !== null ? appUsedBytes : volumeUsedBytes;
|
||||
const denominatorBytes = assignedLimitBytes || totalBytes;
|
||||
return { totalBytes, freeBytes, availableBytes, usedBytes, assignedLimitBytes, percent: (usedBytes / denominatorBytes) * 100 };
|
||||
})() : 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()),
|
||||
};
|
||||
}
|
||||
|
||||
function numberEnv(name, fallback) {
|
||||
@@ -663,6 +807,25 @@ async function syncCaddy() {
|
||||
|
||||
|
||||
let configDrift = { checkedAt: null, drift: false, detail: null };
|
||||
// storage.integrity() runs a full PRAGMA integrity_check -- a complete scan of the entire SQLite
|
||||
// database file for corruption. It's one of the most expensive operations SQLite can run, its
|
||||
// cost scales with total database size, and because this app's SQLite queries run synchronously,
|
||||
// it blocks the whole single-threaded server for its full duration while it runs -- not just the
|
||||
// request that triggered it. dashboardSnapshot() used to call it on EVERY /api/dashboard fetch
|
||||
// just to compute one cosmetic "Healthy"/"Needs attention" label, which is why unrelated requests
|
||||
// (confirmed via container logs: /api/system/security, /api/logs/prune/preview) were getting
|
||||
// stuck behind it in lockstep, all finishing at nearly the same multi-second mark regardless of
|
||||
// what they actually needed to do. A dashboard status badge doesn't need a fresh, exhaustive
|
||||
// integrity scan on every single poll -- checking it periodically in the background and caching
|
||||
// the result is more than sufficient, since real corruption doesn't appear and disappear between
|
||||
// one 7-second poll and the next.
|
||||
let databaseIntegrityCache = { checkedAt: null, status: "Healthy" };
|
||||
function refreshDatabaseIntegrityCache() {
|
||||
try {
|
||||
const result = storage.integrity();
|
||||
databaseIntegrityCache = { checkedAt: new Date().toISOString(), status: result.length === 1 && result[0] === "ok" ? "Healthy" : "Needs attention" };
|
||||
} catch (error) { console.warn("Database integrity check failed:", error.message); }
|
||||
}
|
||||
let lastUpstreamCheckAt = null;
|
||||
let lastAccessLogImportAt = null;
|
||||
let lastKnownGoodCaddyConfig = null;
|
||||
@@ -729,13 +892,14 @@ function publicStream(stream) {
|
||||
|
||||
// --- Certificate inventory & domain readiness diagnostics --------------------------------------
|
||||
async function walkFiles(directory) {
|
||||
const output = [];
|
||||
for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) {
|
||||
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
|
||||
const results = await Promise.all(entries.map(entry => {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) output.push(...await walkFiles(fullPath));
|
||||
else if (entry.isFile()) output.push(fullPath);
|
||||
}
|
||||
return output;
|
||||
if (entry.isDirectory()) return walkFiles(fullPath);
|
||||
if (entry.isFile()) return [fullPath];
|
||||
return [];
|
||||
}));
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
function certificateNames(certificate) {
|
||||
@@ -745,18 +909,19 @@ 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 })));
|
||||
const parsed = [];
|
||||
const certificateFiles = [...await walkFiles(certificateDir), ...await walkFiles(customCertificatesDir)];
|
||||
for (const filename of certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file))) {
|
||||
const [managedCertificateFiles, customCertificateFiles] = await Promise.all([walkFiles(certificateDir), walkFiles(customCertificatesDir)]);
|
||||
const certificateFiles = [...managedCertificateFiles, ...customCertificateFiles];
|
||||
const parsed = (await Promise.all(certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file)).map(async filename => {
|
||||
try {
|
||||
const certificate = new crypto.X509Certificate(await fsp.readFile(filename));
|
||||
const stat = await fsp.stat(filename);
|
||||
parsed.push({ certificate, names: certificateNames(certificate), updatedAt: stat.mtime.toISOString(), filename, source: filename.startsWith(customCertificatesDir) ? "Custom upload" : "Caddy / ACME" });
|
||||
} catch { /* Ignore non-certificate PEM files and unreadable entries. */ }
|
||||
}
|
||||
const [contents, stat] = await Promise.all([fsp.readFile(filename), fsp.stat(filename)]);
|
||||
const certificate = new crypto.X509Certificate(contents);
|
||||
return { certificate, names: certificateNames(certificate), updatedAt: stat.mtime.toISOString(), filename, source: filename.startsWith(customCertificatesDir) ? "Custom upload" : "Caddy / ACME" };
|
||||
} catch { return null; /* Ignore non-certificate PEM files and unreadable entries. */ }
|
||||
}))).filter(Boolean);
|
||||
const certificates = configuredDomains.map(item => {
|
||||
const found = parsed.find(entry => entry.names.some(name => name === item.domain || (name.startsWith("*.") && item.domain.endsWith(name.slice(1)))));
|
||||
if (!found) {
|
||||
@@ -771,7 +936,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) {
|
||||
@@ -793,10 +960,9 @@ async function pruneOrphanedCertificates(candidateDomains) {
|
||||
}
|
||||
|
||||
|
||||
async function domainReadiness() {
|
||||
async function domainReadiness(precomputedCertificates) {
|
||||
const routes = [...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).flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain })));
|
||||
const certs = await certificateInventory();
|
||||
const [httpResponding, httpsResponding] = await Promise.all([tcpProbe(80), tcpProbe(443)]);
|
||||
const [certs, httpResponding, httpsResponding] = await Promise.all([precomputedCertificates ? Promise.resolve(precomputedCertificates) : certificateInventory(), tcpProbe(80), tcpProbe(443)]);
|
||||
return Promise.all(routes.map(async item => {
|
||||
let addresses = [], dnsError = null;
|
||||
try { addresses = [...new Set((await dns.lookup(item.domain, { all: true })).map(value => value.address))]; } catch (error) { dnsError = error.code || error.message; }
|
||||
@@ -871,12 +1037,30 @@ async function readAccessLogs(limit = 100, host = "") {
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Diagnostic timing (v0.16.44): this job reads Caddy's access-log files, JSON.parses up to 5000
|
||||
// lines, hashes each one, and batch-inserts them -- all synchronous work that runs on Node's single
|
||||
// thread and therefore blocks every other request in the app for its full duration, every time it
|
||||
// runs (every 30 seconds). A user reported requests as simple as GET /api/sites randomly taking
|
||||
// 6-14 seconds with a Network-tab Timing capture showing nearly all of it as server-side "Waiting"
|
||||
// (TTFB) rather than connection/DNS time -- consistent with getting stuck behind a job like this
|
||||
// one. Logging real durations here (only when a run takes long enough to plausibly explain that)
|
||||
// gives proof of whether this is the actual cause before changing how it works, rather than
|
||||
// shipping a fourth guess.
|
||||
async function importAccessLogsToSqlite() {
|
||||
if (!storage?.recordAccessEvents) return;
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const readStarted = performance.now();
|
||||
const entries = await readAccessLogs(5000);
|
||||
const readMs = Math.round(performance.now() - readStarted);
|
||||
const hashStarted = performance.now();
|
||||
const events = entries.map(entry => ({ ...entry, source: crypto.createHash("sha1").update(JSON.stringify([entry.at, entry.host, entry.method, entry.uri, entry.status, entry.size, entry.durationMs, entry.remoteIp])).digest("hex") }));
|
||||
const hashMs = Math.round(performance.now() - hashStarted);
|
||||
const insertStarted = performance.now();
|
||||
storage.recordAccessEvents(events);
|
||||
const insertMs = Math.round(performance.now() - insertStarted);
|
||||
const totalMs = Math.round(performance.now() - startedAt);
|
||||
if (totalMs > 500) console.warn(`[perf] importAccessLogsToSqlite took ${totalMs}ms for ${entries.length} entries (read ${readMs}ms, hash ${hashMs}ms, insert ${insertMs}ms) -- this blocks every other request while it runs.`);
|
||||
} catch (error) { console.warn("Could not import access logs into SQLite:", error.message); }
|
||||
}
|
||||
|
||||
@@ -954,12 +1138,12 @@ async function cacheIcon(slug) {
|
||||
|
||||
// --- Dashboard snapshot: aggregates health/status across every subsystem for the
|
||||
// Overview page and the /api/dashboard endpoint --------------------------------------------
|
||||
async function dashboardSnapshot() {
|
||||
async function dashboardSnapshot(precomputedCertificates) {
|
||||
const hosted = sites.map(publicSite);
|
||||
const proxyHosts = proxies.map(publicProxy);
|
||||
const enabledStreams = streams.filter(item => item.enabled !== false);
|
||||
const streamingPorts = { total: enabledStreams.length, listening: enabledStreams.filter(item => activeStreams.has(item.id)).length };
|
||||
const certificates = await certificateInventory();
|
||||
const certificates = precomputedCertificates || await certificateInventory();
|
||||
const tlsDomains = [...sites, ...proxies].filter(item => item.enabled && item.domain && item.tls !== "http").length;
|
||||
const [storageWritable, gatewayResponding, httpResponding, httpsResponding] = await Promise.all([
|
||||
fsp.access(dataDir, fs.constants.R_OK | fs.constants.W_OK).then(() => true).catch(() => false),
|
||||
@@ -983,7 +1167,7 @@ async function dashboardSnapshot() {
|
||||
for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` });
|
||||
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration/defaults" });
|
||||
const disk = await fsp.statfs(dataDir).catch(() => null);
|
||||
const databaseIntegrity = storage.integrity();
|
||||
// See refreshDatabaseIntegrityCache() above -- this used to be a live storage.integrity() call on every fetch.
|
||||
return {
|
||||
checkedAt: new Date().toISOString(),
|
||||
gateway: { ...gatewayProbe, lastReload: lastGatewayReload },
|
||||
@@ -1010,7 +1194,7 @@ async function dashboardSnapshot() {
|
||||
caddyVersion,
|
||||
nodeVersion: process.version,
|
||||
databaseEngine: "SQLite",
|
||||
databaseStatus: databaseIntegrity.length === 1 && databaseIntegrity[0] === "ok" ? "Healthy" : "Needs attention",
|
||||
databaseStatus: databaseIntegrityCache.status,
|
||||
databaseBytes: (await fsp.stat(storage.databasePath).catch(() => null))?.size || 0,
|
||||
publicIp: publicIpState.address,
|
||||
publicIpCheckedAt: publicIpState.checkedAt,
|
||||
@@ -1347,6 +1531,20 @@ app.disable("x-powered-by");
|
||||
// HTTP layer: Express app setup, auth middleware, and every /api/* route.
|
||||
// Routes below are grouped by area; see the section comments for each group.
|
||||
// ============================================================================================
|
||||
// Diagnostic timing (v0.16.44): logs any request that takes noticeably long to answer, alongside
|
||||
// the importAccessLogsToSqlite instrumentation above -- together these should show, in the
|
||||
// container's own logs, whether a slow page load lines up with a background job's run window or
|
||||
// is a slow request in its own right. Placed first so it wraps the full request, including any
|
||||
// auth/body-parsing work below it. Remove once the real cause behind reported multi-second page
|
||||
// loads is confirmed and fixed; this is a temporary aid, not a permanent feature.
|
||||
app.use((req, res, next) => {
|
||||
const startedAt = performance.now();
|
||||
res.on("finish", () => {
|
||||
const durationMs = Math.round(performance.now() - startedAt);
|
||||
if (durationMs > 1000) console.warn(`[perf] ${req.method} ${req.originalUrl} took ${durationMs}ms`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.get(["/", "/index.html"], (req, res) => {
|
||||
@@ -1581,13 +1779,19 @@ 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) => {
|
||||
try { res.json(await systemHealthSnapshot()); } catch (error) { next(error); }
|
||||
});
|
||||
app.get("/api/system/storage", async (req, res, next) => {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
try {
|
||||
const breakdown = {};
|
||||
for (const [key, dir] of Object.entries({ sites: sitesDir, backups: backupsDir, certificates: certificatesRoot, logs: logsDir, database: path.join(dataDir, "database") })) {
|
||||
breakdown[key] = await directorySize(dir);
|
||||
}
|
||||
const breakdownDirs = { sites: sitesDir, backups: backupsDir, certificates: certificatesRoot, logs: logsDir, database: path.join(dataDir, "database") };
|
||||
const breakdown = Object.fromEntries(await Promise.all(Object.entries(breakdownDirs).map(async ([key, dir]) => [key, await directorySize(dir)])));
|
||||
let capacity = null;
|
||||
try {
|
||||
const stats = await fsp.statfs(dataDir);
|
||||
@@ -1783,7 +1987,12 @@ app.get("/api/certificates", async (req, res, next) => {
|
||||
catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/health/check", async (req, res, next) => {
|
||||
try { await checkAllProxies(); res.json({ dashboard: await dashboardSnapshot(), certificates: await certificateInventory(), readiness: await domainReadiness() }); }
|
||||
try {
|
||||
await checkAllProxies();
|
||||
const certificates = await certificateInventory();
|
||||
const [dashboard, readiness] = await Promise.all([dashboardSnapshot(certificates), domainReadiness(certificates)]);
|
||||
res.json({ dashboard, certificates, readiness });
|
||||
}
|
||||
catch (error) { next(error); }
|
||||
});
|
||||
app.get("/api/readiness", async (req, res, next) => { try { res.json({ checkedAt: new Date().toISOString(), routes: await domainReadiness() }); } catch (error) { next(error); } });
|
||||
@@ -1795,8 +2004,9 @@ app.get("/api/support-report", async (req, res, next) => {
|
||||
try {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
const certificateReport = await certificateInventory();
|
||||
const readiness = await domainReadiness(certificateReport);
|
||||
certificateReport.latestError = certificateReport.latestError ? { present:true, at:certificateReport.latestError.at } : null;
|
||||
const report = { product: "Site Gateway", generatedAt: new Date().toISOString(), version: appVersion, caddyVersion, nodeVersion: process.version, storage: { engine: "SQLite", integrity: storage.integrity() }, gateway: { healthy: !gatewayError, lastReload: lastGatewayReload }, routes: { hosted: sites.map(({ id,name,domain,tls,enabled,port }) => ({ id,name,domain,tls,enabled,port })), proxies: proxies.map(({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected }) => ({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected })), redirects: redirects.map(({ id,name,domain,tls,enabled,code }) => ({ id,name,domain,tls,enabled,code })) }, certificates: certificateReport, readiness: await domainReadiness(), recentEvents: recentActivity.slice(0,20).map(item => ({ at:item.at, status:item.status, message:item.status === "error" ? "Operational error recorded; review the protected in-app event log for details." : item.message })) };
|
||||
const report = { product: "Site Gateway", generatedAt: new Date().toISOString(), version: appVersion, caddyVersion, nodeVersion: process.version, storage: { engine: "SQLite", integrity: storage.integrity() }, gateway: { healthy: !gatewayError, lastReload: lastGatewayReload }, routes: { hosted: sites.map(({ id,name,domain,tls,enabled,port }) => ({ id,name,domain,tls,enabled,port })), proxies: proxies.map(({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected }) => ({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected })), redirects: redirects.map(({ id,name,domain,tls,enabled,code }) => ({ id,name,domain,tls,enabled,code })) }, certificates: certificateReport, readiness, recentEvents: recentActivity.slice(0,20).map(item => ({ at:item.at, status:item.status, message:item.status === "error" ? "Operational error recorded; review the protected in-app event log for details." : item.message })) };
|
||||
res.setHeader("Content-Disposition", `attachment; filename="site-gateway-support-${new Date().toISOString().slice(0,10)}.json"`); res.type("json").send(JSON.stringify(report, null, 2));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -2420,6 +2630,10 @@ setTimeout(() => cleanupOldPruneSnapshots().catch(error => console.warn("Startup
|
||||
setInterval(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
|
||||
setTimeout(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10000).unref();
|
||||
setInterval(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10 * 60000).unref();
|
||||
// Runs the (expensive, synchronous, whole-server-blocking) database integrity scan once shortly
|
||||
// after boot and then every 30 minutes in the background, rather than on every dashboard fetch.
|
||||
setTimeout(refreshDatabaseIntegrityCache, 5000).unref();
|
||||
setInterval(refreshDatabaseIntegrityCache, 30 * 60000).unref();
|
||||
|
||||
|
||||
// --- Scheduled jobs: automatic backups, log pruning, public IP checks, graceful shutdown ---------------------------------
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id));
|
||||
CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS audit_events_instance_created ON audit_events(instance_id,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
|
||||
|
||||
Reference in New Issue
Block a user