Compare commits

..

75 Commits

Author SHA1 Message Date
marvin 557670a78b Restore GitHub's ghcr.io publish path; revert Dashboard v2 test change (v0.16.78)
Build and publish container / publish (push) Successful in 12m19s
2026-09-21 16:16:41 -04:00
marvin 00ec6ff0d9 Pin Trivy to an existing release version (v0.74.0) (v0.16.77)
Build and publish container / publish (push) Successful in 11m20s
2026-09-21 15:50:07 -04:00
marvin 454d2dc8f7 Replace flaky git-based Trivy installer with pinned binary curl (v0.16.76)
Build and publish container / publish (push) Has been cancelled
2026-09-21 15:41:25 -04:00
marvin 7153729e9c Use neutral registry secret names
Build and publish container / publish (push) Failing after 11m32s
2026-09-21 14:30:27 -04:00
marvin 28d19266e9 Publish container through Gitea registry
Build and publish container / publish (push) Failing after 10m9s
2026-09-21 14:23:23 -04:00
marvin dda6c517fc Smoke test: verify Gitea push pipeline
Build and publish container / publish (push) Failing after 9m57s
2026-09-21 13:09:57 -04:00
marvin 887cd2dfcc Distinguish disabled routes from monitoring-off routes in status text 2026-09-21 10:03:10 -04:00
marvin 227823c534 Make System panel top accent bar reflect CPU/memory/swap/disk tone 2026-09-20 17:32:39 -04:00
marvin 216ce1b094 Add properly sized favicon set for browsers that skip oversized icons 2026-09-20 16:30:39 -04:00
marvin 6821a30b65 Prefix Hosted Site file-replace log entries to match other Hosted Site actions 2026-09-20 16:16:31 -04:00
marvin 4e4823b0f7 Match Access Logs table styling to the unified log-table pattern 2026-09-20 16:12:40 -04:00
marvin 747c776a84 Add resource-limit env options to all compose files and .env.example 2026-09-20 15:54:09 -04:00
marvin 5b5ba9b3be Wrap Audit log table in dashboard-panel card matching Backup history 2026-09-20 15:42:37 -04:00
marvin 53b47b77a6 Convert Audit log tab to pinned-header table matching other log pages 2026-09-20 15:35:44 -04:00
marvin 146b7f97ae Log distinct field-level change summaries for Hosted Site/Proxy/Redirect/Streaming edits 2026-09-20 15:19:25 -04:00
marvin 238c5aedc2 Fix invisible Configuration chip color, version-line alignment, and restore full-width Backup history table 2026-09-20 14:18:19 -04:00
marvin ba0f3b68e2 Fix Backup history not refreshing on create/delete/import, and its column spacing 2026-09-20 13:50:21 -04:00
marvin dbc143dfbd Convert Backup history to pinned-header table matching Access Logs / Gateway Events 2026-09-20 13:41:18 -04:00
marvin a0565eeaf4 Fix Certificates tile count mismatch and replace square icon with key icon 2026-09-20 13:14:56 -04:00
marvin 9a81453b86 Revert v0.16.60 panel-centering, fix Documentation intro empty-space correctly 2026-09-20 13:02:44 -04:00
marvin 8e2a195ef3 Documentation manual: cap intro header width so it flows with the page 2026-09-20 12:56:38 -04:00
marvin 0030820cab Release-readiness cleanup: docs-intro CSS bug, stale documentation, README/manifest versions 2026-09-20 12:39:12 -04:00
marvin 3bda4088c4 Fix Gateway events column widths being overridden by the Performance table's CSS 2026-09-20 12:04:27 -04:00
marvin 547f14d874 Fix Gateway events table layout, merge Disk stats, color Runtime values by threshold 2026-09-20 11:55:24 -04:00
marvin 424ee26e00 Add database integrity and disk usage jobs to Scheduled jobs 2026-09-20 11:31:34 -04:00
marvin 8bca71d9b9 Align Configure thresholds link, extend upstream coloring to cards, convert Gateway events to a table 2026-09-20 11:26:32 -04:00
marvin a199806554 Add accurate three-state coloring to the Certificates page Upstream column 2026-09-20 11:06:43 -04:00
marvin 0685f4ee84 Fix Renewal thresholds dialog layout on the Certificates page 2026-09-20 10:58:57 -04:00
marvin bf4859c41e Fix Certificates page Upstream column showing Not configured for Hosted Sites 2026-09-20 10:54:40 -04:00
marvin 54e9a95bd4 Merge Certificates page into one sticky-header table 2026-09-20 10:44:03 -04:00
marvin 84ba1f2426 Add Hide not configured toggle to the Performance page's per-route table 2026-09-20 01:14:04 -04:00
marvin b227bb568a Add My Account and Documentation to the shared header spacing rule 2026-09-20 00:52:34 -04:00
marvin 7b11730c8e Fix header layout regression, remove exploit-block toggle and perf logging, cache disk-usage check 2026-09-20 00:29:37 -04:00
marvin 6cd51a8a3f Show page-scoped refresh button on every page, sized to match primary buttons 2026-09-20 00:05:02 -04:00
marvin 7d5842448e Cache the database integrity check instead of running it on every dashboard fetch 2026-09-19 23:33:38 -04:00
marvin 40a68b3b96 Add temporary timing diagnostics for slow requests and the access-log import job 2026-09-19 23:24:19 -04:00
marvin bf524e5835 Scope page refreshes to only the data each view needs 2026-09-19 22:57:17 -04:00
marvin 926296d0d7 Stop the retention-preview timer from polling every page forever and index audit_events 2026-09-19 22:42:35 -04:00
marvin 578d39a3ad Cache certificate inventory and stop refreshPendingProxies from re-triggering a full refresh 2026-09-19 22:24:13 -04:00
marvin 8e6a5f9196 Sync Version panel's Uptime to the shared 7-second hero poll 2026-09-19 20:44:08 -04:00
marvin cf313f7abf Simplify Dashboard Uptime onto the shared 7-second hero poll 2026-09-19 20:05:14 -04:00
marvin a413f53577 Fix Dashboard Uptime tile resetting to 0m on page load due to a ticker race condition 2026-09-19 19:45:08 -04:00
marvin ecb4b1ad0a Document hero panel arc; sync Dashboard Throughput chip to the hero's refresh cadence 2026-09-19 16:34:58 -04:00
marvin 39f10f7378 Open /api/system/health to all signed-in users so the Dashboard hero works for non-admins 2026-09-19 16:27:18 -04:00
marvin 4ff29d4b2a Unify Dashboard and System tab hero panels; move version/database/IP metadata to Administration 2026-09-19 16:25:27 -04:00
marvin af5223dc63 Fix Network/Throughput helper text misalignment on the System hero panel 2026-09-19 15:55:13 -04:00
marvin bf93c009f0 Round Network stat's rate before formatting to fix layout-breaking decimals 2026-09-19 15:50:52 -04:00
marvin 9b80114e5b Fix DATA_DIR_LIMIT_GB comparing against whole-volume usage instead of Site Gateway's own footprint 2026-09-19 15:43:22 -04:00
marvin 813dde1f80 Add System tab hero panel auto-refresh and DATA_DIR_LIMIT_GB disk allowance 2026-09-19 15:38:28 -04:00
marvin 8b48e0db6c Fix CPU quota denominator and swap display on the System hero panel 2026-09-19 15:23:03 -04:00
marvin 992215151d Add System tab hero panel: CPU/memory/swap/disk/network + throughput 2026-09-19 14:46:47 -04:00
marvin b95a20942e Match API Access summary bar height to every other tab's summary bar (v0.16.28) 2026-09-19 14:04:54 -04:00
marvin 16dbbfec4e Fix API summary bar height and Pick container alignment for real (v0.16.27) 2026-09-19 13:50:46 -04:00
marvin 7e8e604df3 Shorten Docker socket helper text, fix Pick container alignment, fix summary bar wrapping (v0.16.26) 2026-09-19 13:26:41 -04:00
marvin 79fd947adb Parallelize filesystem walks across System/Certificates and remove duplicate cert scans (v0.16.25) 2026-09-19 12:55:43 -04:00
marvin 496716d4b2 Auto-reconnect and reload after a successful restart (v0.16.24) 2026-09-19 12:50:35 -04:00
marvin 5ac088025a Fix Restart application button doing nothing after confirmation (v0.16.23) 2026-09-19 12:40:40 -04:00
marvin ededa5a76f Fix Docker socket detection when the socket is group-owned, not world-readable (v0.16.22) 2026-09-19 09:26:23 -04:00
marvin 98b0884dcc Give API tokens full tile parity (icons, menu, hide-revoked); fix Groups icon bug (v0.16.21) 2026-09-18 22:51:27 -04:00
marvin aeff4d5773 Align frontend with backend role enforcement; correct role documentation (v0.16.20) 2026-09-18 22:40:58 -04:00
marvin 3df8f5db7f Move API token creation to shared header, expand docs, update README (v0.16.19) 2026-09-18 22:13:55 -04:00
marvin 4acfbd97bf Rework API Access tab to match Users/Groups tile layout (v0.16.18) 2026-09-18 21:58:34 -04:00
marvin 84f9189b06 Fix Backup type helper text showing both explanations at once (v0.16.17) 2026-09-18 21:52:30 -04:00
marvin 25a12f500f Backup type labels, drop Performance outliers/error column, sticky table header, badge unconfigured domains, fix Users loading flash (v0.16.16) 2026-09-18 21:25:58 -04:00
marvin 1c34407d65 Clean up Groups tab heading, add spacing under Administration subtitle (v0.16.15) 2026-09-18 20:54:57 -04:00
marvin e35cbfa2ab Fix Create button drifting out of sync when switching admin tabs (v0.16.14) 2026-09-18 19:25:28 -04:00
marvin 9aa111b7fe Add Groups stat bar, align Create group button, fix Logs/Performance/Certificates header spacing (v0.16.13) 2026-09-18 19:14:08 -04:00
marvin 17f1624c7c Fix Backup history getting stuck on Loading state (v0.16.12) 2026-09-18 18:49:08 -04:00
marvin e705e814af Merge Sync and Reload & restart into one Sync & control section (v0.16.11) 2026-09-18 18:41:36 -04:00
marvin f3294e0ada Clean up orphaned pre-prune snapshots inflating Storage usage (v0.16.10) 2026-09-18 18:32:01 -04:00
marvin 00f990f8b0 Side-by-side Integrations tiles, fix Sync button placement, track remaining job last-run times (v0.16.8) 2026-09-18 18:17:51 -04:00
marvin 2577493225 Give System/API Access tabs static shells to fix reload blink, restyle Scheduled Jobs with last-run data (v0.16.7) 2026-09-18 17:58:54 -04:00
marvin 23784accde Fix Scheduled Jobs reading the wrong dashboard property path, fix Docker socket tile width (v0.16.6) 2026-09-18 17:11:03 -04:00
marvin cb94e5c63c Match Docker socket status to health-tile styling, fix restart-status spacing (v0.16.5) 2026-09-18 16:51:55 -04:00
marvin 157c680e58 Reword restart-status Docker socket message to avoid duplicating the container-picker message (v0.16.4) 2026-09-18 16:43:39 -04:00
19 changed files with 1222 additions and 279 deletions
+7
View File
@@ -8,3 +8,10 @@ ACME_EMAIL=you@example.com
HTTP_PORT=80 HTTP_PORT=80
HTTPS_PORT=443 HTTPS_PORT=443
BACKUP_PASSWORD= BACKUP_PASSWORD=
# Optional resource-panel tuning (see compose.release.yaml). Leave commented
# to run unlimited / compare Disk against the whole volume.
# DATA_DIR_LIMIT_GB=30
# MEM_LIMIT=2g
# CPU_LIMIT=2
# CPUSET=0,1
+51 -12
View File
@@ -23,15 +23,37 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Sign in to Gitea Container Registry
if: ${{ github.server_url != 'https://github.com' }}
uses: docker/login-action@v3
with:
registry: git.us2plus2.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Sign in to GitHub Container Registry - name: Sign in to GitHub Container Registry
if: ${{ github.server_url == 'https://github.com' }}
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image tags - name: Generate image tags (Gitea)
id: meta id: meta-gitea
if: ${{ github.server_url != 'https://github.com' }}
uses: docker/metadata-action@v5
with:
images: git.us2plus2.com/marvin/site-gateway
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=raw,value=alpha,enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-alpha.') }}
type=sha
- name: Generate image tags (GitHub)
id: meta-github
if: ${{ github.server_url == 'https://github.com' }}
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/${{ github.repository }} images: ghcr.io/${{ github.repository }}
@@ -52,21 +74,38 @@ jobs:
- name: Verify Node and built-in SQLite - name: Verify Node and built-in SQLite
run: docker run --rm --entrypoint node site-gateway:smoke-test --input-type=module -e "import { DatabaseSync } from 'node:sqlite'; const db = new DatabaseSync(':memory:'); db.exec('CREATE TABLE smoke (id INTEGER)'); db.close();" run: docker run --rm --entrypoint node site-gateway:smoke-test --input-type=module -e "import { DatabaseSync } from 'node:sqlite'; const db = new DatabaseSync(':memory:'); db.exec('CREATE TABLE smoke (id INTEGER)'); db.close();"
- name: Scan image for vulnerabilities - name: Install Trivy
uses: aquasecurity/trivy-action@0.35.0 run: |
with: set -e
image-ref: site-gateway:smoke-test TRIVY_VERSION=0.74.0
severity: CRITICAL,HIGH curl -sfL "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -o /tmp/trivy.tar.gz
exit-code: "0" tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
format: table sudo mv /tmp/trivy /usr/local/bin/trivy
trivy --version
- name: Build and publish - name: Scan image for vulnerabilities
run: trivy image --severity CRITICAL,HIGH --exit-code 0 --format table site-gateway:smoke-test
- name: Build and publish (Gitea)
if: ${{ github.server_url != 'https://github.com' }}
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta-gitea.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta-gitea.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and publish (GitHub)
if: ${{ github.server_url == 'https://github.com' }}
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-github.outputs.tags }}
labels: ${{ steps.meta-github.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+6 -4
View File
@@ -12,7 +12,7 @@
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white"> <img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2"> <img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0"> <img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.3-62E6A7"> <img alt="Version" src="https://img.shields.io/badge/version-0.16.58-62E6A7">
</p> </p>
<p> <p>
<a href="#why-site-gateway">Why Site Gateway</a> · <a href="#why-site-gateway">Why Site Gateway</a> ·
@@ -40,13 +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 | | 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. - **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. - **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. - **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. - **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. - **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. - **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
- **Performance and logs** — request throughput, response times, and rotating access/activity logs per host. - **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. - **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. 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.
@@ -93,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 | | `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 | | `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` | `/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 | | `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`) | | `PUID` / `PGID` | `1000` / `1000` | User/group the container writes files as (Unraid: `99`/`100`) |
| `ACME_EMAIL` | empty | Optional certificate account email | | `ACME_EMAIL` | empty | Optional certificate account email |
@@ -101,7 +103,7 @@ At startup, the container creates the complete `/data` hierarchy, applies `PUID`
## Unraid ## Unraid
1. Add the container from **Docker → Add Container** using the image `ghcr.io/mfwadejr/site-gateway2:latest`, or search Community Applications once a template is published. 1. Add the container from **Docker → Add Container** using the image `git.us2plus2.com/marvin/site-gateway:latest`, or search Community Applications once a template is published.
2. Map ports `80`, `443` (TCP+UDP), `8080`, and `9000-9099` as above, plus any Streaming Host ports you plan to use. 2. Map ports `80`, `443` (TCP+UDP), `8080`, and `9000-9099` as above, plus any Streaming Host ports you plan to use.
3. Map one path, e.g. `/mnt/user/appdata/site-gateway:/data`. 3. Map one path, e.g. `/mnt/user/appdata/site-gateway:/data`.
4. Set `PUID=99` and `PGID=100` so the container writes to `/data` as the `nobody`/`users` account Unraid expects. 4. Set `PUID=99` and `PGID=100` so the container writes to `/data` as the `nobody`/`users` account Unraid expects.
+148 -2
View File
@@ -118,8 +118,6 @@ Roughly in priority order:
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain. - **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain.
- **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships. - **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships.
- **Browsable backup/restore history** — today a restore validates and rolls back safely, but there's no UI history of past backups beyond what's on disk.
- **Container picker for Proxy/Streaming targets** — letting a target be selected from a list of running Docker containers instead of typed as an IP/hostname, gated behind an opt-in Docker-socket mount since it needs real access to the Engine API. Also needs a shared Docker network between Site Gateway and the target container to actually be reachable, not just discoverable.
- **Tailscale integration** — documented patterns exist today (host-level Tailscale for private dashboard access, a sidecar container for proxying to tailnet-only targets, `tailscale serve`/`funnel` for exposing a route without opening router ports), but nothing is built into Site Gateway itself yet. - **Tailscale integration** — documented patterns exist today (host-level Tailscale for private dashboard access, a sidecar container for proxying to tailnet-only targets, `tailscale serve`/`funnel` for exposing a route without opening router ports), but nothing is built into Site Gateway itself yet.
- **Dynamic DNS** and **deeper Caddy controls** for advanced users who outgrow the guided options. - **Dynamic DNS** and **deeper Caddy controls** for advanced users who outgrow the guided options.
- **Rate limiting** and other specialist gateway controls. - **Rate limiting** and other specialist gateway controls.
@@ -134,3 +132,151 @@ Roughly in priority order:
- Arbitrary Caddy snippets substantially increase support and security risk and stay an expert-only, size-limited, validated feature. - Arbitrary Caddy snippets substantially increase support and security risk and stay an expert-only, size-limited, validated feature.
`v0.16.3` fixes the System tab's real population and layout bugs reported after v0.16.2 went live. Root cause of the empty sections and the tab "flickering" on refresh: the System panel is created dynamically (like Groups, Audit log, and API Access) the first time `refresh()` runs after login, but the app's render order calls the tab-visibility toggle *before* that panel exists — so a brand-new panel is created already carrying the `hidden` class, and `renderSystemStatus()` was guarded to skip populating anything while its panel was hidden. Result: on first render nothing gets filled in, and only after the *next* periodic poll (once the panel exists and the visibility toggle can find and unhide it) does it get one more chance — which looked like the tab disappearing and reappearing. Fix: `renderSystemStatus()` now always populates its content regardless of the panel's current visibility, matching how every other dynamically-created admin panel (Groups, Retention, API Access) already behaves. Also added the missing spacing between the System tab's stacked `.dashboard-panel` cards (`[data-admin-panel="system"]>.dashboard-panel+.dashboard-panel{margin-top:18px}`) — the generic `.settings-panel` wrapper never had a gap rule for its children, so the cards were rendering edge-to-edge. `v0.16.3` fixes the System tab's real population and layout bugs reported after v0.16.2 went live. Root cause of the empty sections and the tab "flickering" on refresh: the System panel is created dynamically (like Groups, Audit log, and API Access) the first time `refresh()` runs after login, but the app's render order calls the tab-visibility toggle *before* that panel exists — so a brand-new panel is created already carrying the `hidden` class, and `renderSystemStatus()` was guarded to skip populating anything while its panel was hidden. Result: on first render nothing gets filled in, and only after the *next* periodic poll (once the panel exists and the visibility toggle can find and unhide it) does it get one more chance — which looked like the tab disappearing and reappearing. Fix: `renderSystemStatus()` now always populates its content regardless of the panel's current visibility, matching how every other dynamically-created admin panel (Groups, Retention, API Access) already behaves. Also added the missing spacing between the System tab's stacked `.dashboard-panel` cards (`[data-admin-panel="system"]>.dashboard-panel+.dashboard-panel{margin-top:18px}`) — the generic `.settings-panel` wrapper never had a gap rule for its children, so the cards were rendering edge-to-edge.
`v0.16.4` rewords the Reload & Restart section's "Docker socket not detected" message so it no longer reads as a duplicate of the Docker container-selection message elsewhere on the System tab. Both checks are independent (one gates the proxy-target container picker, the other gates whether Site Gateway can confirm this container will actually come back up before offering a restart), but they previously used the exact same sentence, which looked like a copy-paste mistake. No behavior change — the Restart button is still disabled under the same conditions as before.
`v0.16.5` gives the Docker container-selection status its own `.health-tile` card (matching `BACKUP_PASSWORD` directly above it) instead of a plain paragraph — the two Environment/Integrations rows now look consistent whether Docker's socket is mounted or not. Also adds breathing room between the Reload & Restart buttons and the status line beneath them, which was sitting flush against the button row.
`v0.16.6` fixes the real cause of the System tab's Scheduled jobs section always showing "No scheduled jobs reported.": the dashboard API nests job data under `dashboard.system.jobs`, but the System tab was reading `dashboard.jobs` — one level too shallow, so it was always undefined regardless of what the server returned. Also fixes the Docker socket status tile rendering wider than the `BACKUP_PASSWORD` tile above it — it wasn't wrapped in the same `.health-grid` container, so it spanned the full panel width instead of matching the two-column tile layout used everywhere else on the System tab.
`v0.16.7` fixes the real cause of the System tab visibly blinking in and out on every page reload: unlike every other Administration tab (Users, Groups, Gateway defaults, Audit log, Backup & restore, Logs & Retention, Danger Zone — all static HTML present from the first paint), System and API Access were built entirely by JavaScript after the initial data fetch completed, so there was a real window on every reload where every other tab was already visible and these two genuinely were not there yet. Confirmed via a screenshot taken mid-reload showing exactly that. Fix: gave System and API Access the same static tab-button-and-panel shell every other tab already has, so they're present immediately; their content still fills in a moment later via JavaScript, same as every other tab already does. Also reworked the Scheduled Jobs section to match the Storage section's card styling (health-tile grid instead of a plain list) and added last-run timestamps where the server tracks them (scheduled backups, log pruning, public IP checks, configuration drift checks).
`v0.16.8` reworks the System tab's Integrations section: the `BACKUP_PASSWORD` and Docker socket status tiles now share a single two-column grid and sit side by side, instead of each occupying its own separate grid and leaving an empty column next to it. Dropped the redundant "Docker container selection" heading text since the tile's own "Docker socket" label already says the same thing. Moved the Sync section's "Resync now" button out of the panel heading row and into its own row below the status text, matching the layout every other actioned section (like Reload & Restart) already uses, instead of crowding the button into the title row. Also added real last-run tracking for the two jobs that previously always showed "No run recorded yet": Upstream checks and Access-log import now record a timestamp every time their interval actually runs.
`v0.16.10` fixes a real storage leak: every log prune (scheduled or manual) takes a `pre-prune-<timestamp>.sqlite` safety snapshot into the backups folder, but these are not `.sgbackup` files — they never appeared in the Backup & Restore list and couldn't be deleted from there, so they accumulated indefinitely and silently inflated the System tab's Storage breakdown even after deleting every visible backup. Added automatic cleanup that keeps only the 3 most recent snapshots after each prune, plus a one-time cleanup on startup so existing accumulated snapshots are cleared out immediately after upgrading rather than waiting for the next prune to run.
`v0.16.11` merges the System tab's separate "Sync" and "Reload & restart" sections into one "Sync & control" panel, since the two were both short, related, single-button gateway-control sections that were falling out of line with the rest of the tab's panel widths on their own. Also adds a spacing rule so the merged panel's status line and description text don't sit flush against each other.
`v0.16.12` fixes the Backup & Restore tab's "Backup history" section getting stuck on "Loading backup history…" indefinitely. Root cause: `renderBackupHistory()` is only invoked from the periodic `refresh()` cycle, and it bailed out before fetching whenever the Backups tab wasn't the currently active admin tab at that moment — but switching admin tabs only toggles CSS visibility, it never re-triggers a fetch. So if a refresh cycle landed while you were on a different tab, the placeholder text was left in place with no later refresh ever replacing it. Same bug class as v0.16.3's System-tab fix; resolved the same way, by always populating the section's content regardless of the panel's current visibility.
`v0.16.13` fixes three Administration/Logs layout inconsistencies found in live use. First, the Groups tab was missing the stat-count bar ("N Administrators · N Standard Users · ...") that every other listing tab (Users) shows, making it look unfinished by comparison — added a matching Enabled/Disabled group count bar. Second, "Create group" lived in its own row inside the Groups panel instead of the shared top-right header button used by "Create user," "New hosted site," and every other creation action — moved it into that same header slot so it behaves and aligns like all the others. Third, the Logs page's "Refresh logs" button (and Performance's and Certificates') sat directly against the first box below it with no gap, because those three pages are the only ones with no status-summary bar to provide the usual spacing under the page header — added a matching top margin so they're consistent with every other page.
`v0.16.14` fixes the Create button (Create user / Create group) disappearing or showing the wrong label after switching Administration tabs. Root cause: a leftover click handler on the admin tabs bar, written before Groups had a Create button at all, still hard-coded "hide the shared Create button unless the tab is Users" and manually poked tab-active/panel-visibility classes directly -- completely independent of and out of sync with the real logic added in v0.16.13's `render()`. Since that same handler also fires when the app restores your last-viewed tab on page load/refresh, it would immediately stomp the button back to the wrong state. Replaced both old handlers with one that simply updates state and calls the real `render()`, so there's a single source of truth for tab switching instead of two handlers disagreeing with each other.
`v0.16.15` cleans up the Groups tab's layout: removed the redundant "Groups / Organize users for Access List permissions." heading, since the tab button and admin panel description already say what the tab is, and it was adding a bare, boxless line of text found nowhere else in Administration once the tab's own Create button moved to the shared header. The Enabled/Disabled stat bar is now the first thing in the panel, structurally matching how the Users tab's own stat bar is positioned. Also added top spacing between the Administration page's subtitle and the row of tab buttons (System, Users, Groups, ...) below it -- that gap had never been set, so the tabs bar sat flush against the subtitle text.
`v0.16.16` ships a batch of fixes found in live use: the Backup type picker (in both the scheduled-backup form and the manual "Create a backup" dialog) no longer shows a long wrapped sentence as the selected value -- it now shows a short "Complete (Recommended)" / "Configuration only" label with the detail moved into the helper text beneath it, and the in-app documentation now explicitly names the "Backup type" field so it's easy to find by search. The Performance page's "Outliers / Slowest requests" section has been removed, along with the per-row error-count badge in the "Throughput by domain" table -- both added noise without being worth the space for most setups. That table's column headers now stay pinned while scrolling instead of scrolling out of view. Rows for domains with no matching Hosted Site, Proxy Host, or Redirect Host are now badged "Not configured" -- that table is built from Caddy's raw access log, so it always included every hostname a request was ever seen for (including scanner/bot traffic hitting made-up subdomains that fall through to the Default Site handler), not just domains you've actually configured; the badge makes that distinction visible instead of leaving it to guesswork. Finally, the Administration Users tab no longer flashes "No users found." for a moment before the user list has actually loaded.
`v0.16.17` fixes the Backup type helper text showing both the Complete and Configuration-only explanations stacked on top of each other on page load or refresh, instead of just the one matching the currently selected option. Root cause: the help text only ever updated on the select's `change` event -- but `renderBackups()` sets the select's value from saved settings on every render without firing a `change` event, so the static placeholder text (which briefly held both sentences as a v0.16.16 authoring mistake) never got replaced until you manually touched the dropdown. Factored the text-selection logic into its own function and call it both on `change` and every time `renderBackups()` runs, so it always matches the select's actual current value.
`v0.16.18` reworks the API Access tab to match the Users and Groups tabs' layout instead of the old plain data-row list: tokens are now shown as tiles in the same card grid Hosted Sites/Users/Groups use, and a stat bar above them breaks down Active/Revoked and Full access/Read-only counts at a glance. No behavior changed -- Revoke still works the same way it always has (a one-way action; there is no re-enable, since a revoked token's secret is treated as compromised). An earlier idea of adding an enable/disable toggle was dropped once it became clear that would require adding real token-reactivation support on the backend, a deliberate security-posture change rather than a layout fix.
`v0.16.19` finishes the API Access tab's alignment with Users and Groups: the "Create token" button now lives in the shared top-right header button used by every other create action instead of its own row inside the panel, and the panel-heading text ("Programmatic access / API access tokens / Issue bearer tokens...") has been removed the same way it was for Groups in v0.16.15, since the tab button's own label already says what the section is -- the stat bar is now the first thing in the panel. Also walked the in-app Documentation view and brought it current with everything shipped since it was last substantively updated: added a full API Access section (creating a token, scope, expiry, the one-time reveal, revoking, and automatic revocation when an issuing administrator's password changes or account is disabled), corrected the Performance section's per-route table description to drop the removed per-row error-count badge and instead document the pinned column headers and the "Not configured" chip added in v0.16.16, and added an API Access entry to the documentation sidebar's contents list.
`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.
`v0.16.47` makes the page-scoped refresh button (added in v0.16.43) consistent across every view instead of appearing on most pages but not Logs, and removes a now-redundant control. The button is repositioned to always sit top-right, immediately to the right of that page's green primary action button (“+ New hosted site”, “Run certificate check”, “Refresh logs”) when one is present, or in that same top-right spot when a page has no primary action button of its own; it now also appears on the Logs page rather than being hidden there. A dedicated CSS rule (`.page-refresh{width:44px;height:44px}`) makes the button exactly the same height as the app's existing 44px primary-button standard (the same convention already used for the Backups and Retention action rows), so it visually lines up with the button beside it instead of looking undersized next to it. The Live Health panel's own separate “↻” refresh icon has been removed from the Dashboard, since the page-level refresh button sitting a few pixels away now does the identical job (`refreshDashboard()`, which repopulates that same panel); `refreshDashboard()` itself is unchanged and still runs on its normal 30-second Overview timer, it just no longer drives a second, separate icon's spinner.
`v0.16.48` is a batch covering five separately-reported items. First, it fixes a real layout regression v0.16.47 introduced: reordering the header's action buttons so the page-refresh icon appeared after the green primary button caused `header`'s `justify-content:space-between` to treat every button as its own flex item and redistribute space between all of them, visibly shifting the green button ("Refresh logs", "Run certificate check", etc.) away from its usual position instead of leaving it in place with the icon simply appended beside it. The buttons are now wrapped in a single `.header-actions` container so `header` only ever splits space between the page title and that one group, and the group's own `gap` keeps its buttons hugging together at the right edge exactly as before v0.16.47. Second, it removes the temporary `[perf]` diagnostic logging added in v0.16.44 (the slow-request middleware and the `importAccessLogsToSqlite` timing breakdown), now fully superseded by v0.16.45's fix and no longer needed. Third, it removes the "Block common exploits" per-Proxy-Host toggle entirely -- its regex-based matcher only ever inspected the request path, never the query string, so it never provided the SQL-injection/XSS protection its label implied; the checkbox, its documentation entry, and every server-side and client-side reference to `blockCommonExploits` are gone. Fourth, it applies the same "cache expensive checks instead of recomputing them on every request" fix used for the database-integrity check in v0.16.45 to the System tab hero panel's disk-usage figure: when `DATA_DIR_LIMIT_GB` is set, the hero panel needs a real recursive walk of `/data` to compute its used-space percentage, and that walk was being redone on every single 7-second hero-panel poll, for every concurrent viewer. It's now computed once shortly after boot and refreshed every 60 seconds in the background (`refreshDataDirSizeCache()`), with the hot request path just reading the cached value -- deployments that don't set `DATA_DIR_LIMIT_GB` are unaffected, since they never triggered this walk in the first place. Fifth, the ROADMAP's own "What's next" section is reconciled against the "Shipped" section above it: two items it listed as upcoming (browsable backup/restore history, a Docker container picker for Proxy/Streaming targets) had already shipped and were removed from the list.
`v0.16.49` fixes the My Account and Documentation pages' cramped spacing between the header subtitle and the first box below it, reported against several earlier releases. The cause was pinned down precisely by measuring pixel gaps across side-by-side screenshots of a correctly-spaced page (Logs) against the two broken ones: Certificates, Performance, and Logs all get their deliberate spacing from one shared rule, `#certificates-view,#performance-view,#logs-view{margin-top:var(--space-7)}`, and My Account and Documentation were simply never added to that selector, so both fell back to a 0px top margin. The fix adds `#account-view` and `#documentation-view` to that same existing rule -- reusing the app's own established spacing value rather than introducing a new one.
`v0.16.50` adds a **Hide not configured** checkbox to the Performance page's Throughput by domain table, matching the API Access page's existing "Hide revoked" toggle in both behavior and placement: unchecked by default, resets on every page reload (no server round-trip, no persisted setting), and right-aligned inline with the descriptive text above the table rather than inside the table header itself. Checking it filters out any row already tagged with the "Not configured" chip -- domains Caddy has logged requests for that don't match a real Hosted Site, Proxy Host, or Redirect Host -- so a table with a lot of scanning/bot noise pointed at random hostnames can be narrowed down to just the domains actually configured in Site Gateway.
`v0.16.51` reworks the Certificates page layout, which had three visually inconsistent, unevenly-spaced blocks stacked on top of each other (a bare certificate inventory list, a borderless "Domain readiness" panel, and a fully-bordered "Renewal thresholds" card) -- the last two had no spacing rule between them at all and rendered flush against one another. Certificate inventory and Domain readiness are now merged into a single sticky-header table (`Domain | Status | Days remaining | Issuer | DNS | TLS | Upstream`), reusing the same table pattern already used on the Performance and Access logs pages, with one row per configured domain instead of two separately-rendered lists keyed off the same data. The deep per-certificate fields that used to live in an inline expandable `<details>` row (issuer, serial number, SHA-256 fingerprint, covered domains, valid-from date, upstream check detail) now open in a click-to-view popup dialog instead, reusing the app's existing `.dialog-card` pattern -- keeping every table row a single fixed height for a clean continuous scroll. The "Renewal thresholds" settings form, which doesn't change per-domain, moved out of a permanent third block into a "Configure thresholds" popup opened from the page header, the same way page-level settings are already surfaced elsewhere in the app.
`v0.16.52` fixes the Certificates page's Upstream column always showing "Not configured" for Hosted Sites, even when the exact same upstream health check was clearly running and healthy on that site's own dashboard card. The cause was a single overly-narrow condition in `domainReadiness()`: `const upstream = item.kind === "Proxy host" ? upstreamHealth.get(item.id) || null : null;` only ever read cached health-check results back out for Proxy hosts, even though `checkAllProxies()` runs that identical check against Hosted Sites too and stores the result in the same `upstreamHealth` map under the same id -- the data existed the whole time, this function just refused to return it for anything that wasn't a Proxy host. The condition now also includes `"Hosted site"`; Redirect hosts are unaffected and correctly continue to show no upstream data, since they have none.
`v0.16.53` fixes the "Configure thresholds" popup on the Certificates page (added in v0.16.51) rendering with its first input floating oddly beside the title instead of below it. The cause: `.settings-form` is a shared two-column CSS grid, and the dialog's heading block was placed as a plain grid child instead of spanning both columns like every other full-width element in a settings form already does (`.dialog-actions`, error text, a `<label>` wrapping a textarea). That left the "Renewing-soon warning" field sitting in the grid's second column of row one, directly beside the heading text. `.settings-form>.dialog-heading` is now added to that same existing full-span rule, so the heading spans the full width and the three threshold fields lay out normally beneath it.
`v0.16.54` gives the Certificates table's Upstream column an accurate three-state color treatment instead of collapsing every non-numeric outcome into a single generic "no response." The underlying `upstreamHealth` data already distinguished four real states -- healthy (has a status code), unmonitored (monitoring intentionally turned off for that host), pending (no check has run yet), and a genuine failure (the check ran and errored or timed out) -- and the Hosted Site / Proxy host cards already labeled these correctly ("Monitoring paused", "Upstream check pending", etc.), but the new table only checked for a numeric status code and printed "no response" for everything else, including deliberately paused monitoring. The column now reads: green "running" dot with the status code for a healthy check, amber "idle" dot with "Monitoring paused" or "Check pending" for the two non-issue states, and a new red `.status-dot.bad` (added to styles.css, reusing the existing `--danger` token) with the actual error message for a real failure -- so a glance at the column now tells you whether something needs attention or is simply not being checked by design.
`v0.16.55` is a batch covering three items reported against the Certificates and Logs pages. First, the "Configure thresholds" link on the Certificates page moves from floating level with the page title down onto the same line as the descriptive text beneath it, using a dedicated `.section-heading-row` flex row instead of the whole heading block being one flex row. Second, the three-state Upstream coloring added to the Certificates table in v0.16.54 (healthy / monitoring paused or check pending / genuine failure) is now applied consistently everywhere else that shows the same `upstreamHealth` data -- the Hosted Site, Proxy Host, and Streaming Host cards' upstream text now also gets a matching amber `.upstream-copy.idle` state for "Monitoring paused" and "Upstream/Target check pending", instead of rendering identically green to a real healthy check the way it did before. Third, the Logs page's "Gateway events" panel is converted from a stacked list of `.event-row` cards into a sticky-header table (`Time | Severity | Category | Message`), matching the Access requests table directly above it on the same page; the Message column is left free to wrap rather than forced onto one line, since event messages are free-form and of varying length, unlike the fixed-format columns elsewhere.
`v0.16.56` adds the two background jobs from v0.16.45 and v0.16.48 (the SQLite integrity re-scan and the /data disk-usage walk, both moved off the request path and onto their own timers to fix the multi-second dashboard freezes reported at the time) to the Scheduled jobs list shown on the Dashboard and Administration -> System page. Both caches already tracked their own `checkedAt` timestamp internally, they just were never surfaced in the `jobs` array both pages already render from -- "Database integrity check" (every 30 minutes) and "Disk usage refresh" (every 60 seconds) now appear alongside Upstream checks, Scheduled backups, Log pruning, Access-log import, Public IP check, and Configuration drift check, with live last-run timestamps the same as every other entry in that list.
`v0.16.57` is a batch of four fixes/improvements against the Logs page and the System-runtime views reported after v0.16.55 and v0.16.56. First, the Gateway events table's Time/Severity/Category columns used percentage widths copied from the Performance table, leaving a lot of empty space around short values on wide screens; they're now fixed pixel widths (190px/110px/140px, same approach the Access requests table already uses), giving the free-form Message column the room it needs. Second, the Gateway events table wasn't visually joined to its heading/filter row above it the way Access requests and the Certificates table are -- the join CSS (`border-top:0`, bottom-only radius) that ties a heading box to the table below it existed for every other table on the app except this one, which is now fixed with the same one-line pattern. Third, the Runtime hero panel's Disk stat (shown on both the Dashboard and Administration -> System) now includes the real host free-space figure in its detail line alongside the assigned-quota percentage (e.g. "480 MB used of 2.0 GB assigned · 316 GB free on host") instead of only showing the quota view -- and the now-redundant standalone "Disk" tile in the System page's Disk usage breakdown panel (which showed the same host free/total figures with no quota context) has been removed, since that panel is otherwise scoped to what Site Gateway itself is storing (Sites, Backups, Certificates, Logs, Database). Fourth, the Runtime hero panel's CPU/Memory/Swap/Disk value text now gets the same amber/red coloring the progress bar underneath it already had at the existing 75%/90% thresholds -- previously only the thin bar changed color as a stat approached its limit, while the large percentage number stayed plain white regardless of severity.
`v0.16.58` fixes the Gateway events column-width fix from v0.16.57 not actually taking effect. The table carries two classes, `performance-table event-table`, and the generic `.performance-table th:nth-child(n+2){width:11.1%;text-align:center}` rule sits later in styles.css than the `.event-table` column rules added in v0.16.57 -- at equal CSS specificity, source order decides, so the later generic rule was silently winning and the fixed pixel widths never applied. The `.event-table` column selectors are now written as `table.event-table th:nth-child(n)`, adding the `table` type selector so they outrank `.performance-table`'s rules by specificity regardless of where either appears in the file; the Severity/Category text also moves from centered to left-aligned, matching the Access requests table and removing the awkward centered-in-a-wide-column look that was part of the same complaint.
`v0.16.59` is a release-readiness cleanup pass, prompted by an upcoming public release: a genuine CSS bug, and several stale claims in the in-app Documentation manual that had drifted from what the app actually does after the Certificates/Logs/Runtime work in v0.16.51 through v0.16.58. Fixed: the Documentation page's intro paragraph was rendering in the bright body-text color instead of muted gray, because the CSS rule targeting it used `p:last-child`, which stopped matching once the search box `<div>` was added after it as the real last child -- it now has its own dedicated class instead of relying on element position. In the manual itself: the Certificates overview article said Upstream health only applied to Proxy Hosts (true before v0.16.52, not since); the Certificates field-reference article said certificate detail came from "expanding" a row and that thresholds were set from a nonexistent "Administration -> Security & Health" location (they're on the Certificates page itself, both predating and unrelated to this session's changes); the Dashboard and System articles' Scheduled Jobs lists were missing the database-integrity and disk-usage-refresh jobs added in v0.16.56; the System article still claimed the Disk usage breakdown showed total/remaining capacity, which moved to the Runtime panel's Disk stat in v0.16.57; and neither the Dashboard nor System Runtime sections mentioned the value-text threshold coloring added in v0.16.57. The Logs article also picked up one clarifying sentence noting Gateway Events now has the same pinned column-header behavior as Access Logs. Outside the app: the README version badge and the ZimaOS App Store manifest's `version`/`update_at` fields were several releases stale (0.16.50 and 0.11.101 respectively) and are now current.
`v0.16.60` fixes the Documentation manual's intro header leaving a large block of empty space on wide screens, by capping the whole `.docs-intro` panel at 900px and centering it. Shipped, then reported back as visibly worse: centering the panel broke its alignment with the Contents sidebar and article panel in `.docs-layout` directly below it, which both remain full-width -- the intro now floats disconnected in the middle of the page instead of lining up with the rest of the manual.
`v0.16.61` reverts v0.16.60's panel-centering approach and fixes the same empty-space issue correctly. The actual cause was the lede paragraph's own `max-width:850px`, set inside a `.docs-intro` panel that already spans the full reading-column width (matching the Contents sidebar + article layout beneath it) -- the paragraph simply stopped well short of the panel's real width. Removing that max-width lets the paragraph flow to fill the panel it already sits in, while the panel itself stays full-width and aligned with the rest of the manual, exactly as it was before v0.16.60.
`v0.16.62` fixes two Dashboard issues found while reviewing a fresh screenshot for the marketing site. First, the Certificates tile's big number and its "X healthy · Y not detected" line disagreed with each other (e.g. showing "11" above "12 healthy") because they came from two different counts: the big number used `tlsDomains`, a route-count over enabled Hosted Sites and Proxy Hosts with TLS on, while the detail line used the certificate inventory's domain-level summary -- which also includes Redirect Hosts and expands any route with alias domains into one entry per domain. The big number now reads from that same certificate summary the detail line already used, so a route with an alias domain (the actual cause of the 11-vs-12 mismatch) is counted consistently in both places; the underlying route-count (`tlsDomains`) is untouched where it's used to gate the HTTPS/443 health probe, since that is a genuinely different question ("is TLS configured at all") from "how many certificates exist." Second, the Certificates tile's icon was a plain unicode square (`▣`) left over from before the tile had a real icon design -- it's now a small key glyph drawn as an inline SVG in the app's existing icon style (stroke-based, `currentColor`), matching the key icon used for Certificates on the marketing website and requested directly against a reference image.
`v0.16.63` converts the Backup history panel (Administration -> Backup & Restore) from a stacked-card timeline to the same pinned-header table used everywhere else records are logged -- Access Logs and Gateway Events. It was the one remaining place in the app presenting a log-like record as a list of `.activity-tile` cards (the same component the Dashboard's Recent Activity feed uses) instead of a table, and looked inconsistent sitting next to those two. The table has four columns -- Time, Action (Backup/Restored/Deleted/Imported), Type (Complete/Configuration/Safety), and Detail (size, a note that a safety backup was taken first, or the failure reason) -- reusing the exact `performance-table`/`event-table` CSS and column widths Gateway Events already established, plus the same green/red `status-dot` convention for success vs. failure. No backend or data changes; `/api/backups/history` already returned everything the new columns needed.
`v0.16.64` fixes two issues reported right after v0.16.63 shipped the new Backup history table. First, creating, deleting, or importing a backup updated the "Stored backups" list immediately but left Backup history showing stale data until something else triggered a full page refresh -- each of those three handlers called `renderBackups()` afterward but never `renderBackupHistory()`; only Restore happened to update it, incidentally, because it runs the app's full `refresh()` cycle afterward for unrelated reasons. All three now also call `renderBackupHistory()` directly, so every action that writes a history entry reflects it immediately, matching what Restore already did by accident. Second, the table looked lopsided -- all its content bunched on the left with a large empty gap on the right -- because it reused Gateway Events' column widths (`190px/110px/140px/auto`), sized for a Message column that's usually full of long text; Backup history's last column is normally a short phrase like "71 MB", so the table stretched to the full panel width with nothing to fill it. The table now has its own column widths (26%/18%/18%/auto) inside a capped max-width (880px) instead of stretching edge-to-edge, so it reads as one balanced block rather than left-aligned content trailing into empty space.
`v0.16.65` fixes three issues reported against the Stored backups card and the new Backup history table right after v0.16.63/v0.16.64. First, the "Configuration only" type chip used the same background color as the surrounding panel (`var(--panel2)` on `var(--panel2)`), making it functionally invisible next to the green "Complete" chip -- it now uses the app's purple accent, tinted the same way every other colored chip already is. Second, the "Site Gateway x.x.x" version line under each chip sat flush with the column's left edge while the chip's own text started a few pixels in from its padding, reading as slightly misaligned -- a `.chip+small{margin-left:var(--space-2)}` rule now lines the two up. Third, and most visible: Backup history's width cap from v0.16.64 (880px) was the wrong fix for the column-spacing complaint -- capping the table made it the one log-style table in the app that doesn't span the panel's full width like Access Logs and Gateway Events do, which is a bigger inconsistency than the spacing problem it was meant to solve. The cap is removed; the wider percentage-based columns introduced alongside it (26%/18%/18%/auto) stay, so the table is full-width again but its columns are no longer crowded into the first ~440px the way the borrowed Gateway Events pixel-widths had them.
`v0.16.66` replaces the generic "X updated" activity message every edit route wrote to Gateway Events -- Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts each logged one identical line regardless of what was actually changed in the save, so a rename, a domain change, a TLS toggle, and a health-check edit were all indistinguishable in the log. Each of the four `PATCH` routes now snapshots the relevant fields before applying the request body, diffs them against the saved result, and writes one comma-joined line naming exactly what changed in that save -- e.g. `Proxy host "Plex" updated — renamed from "Plex Media", target changed to 192.168.1.20:32400, TLS set to automatic.` Multiple fields changed in a single save produce one combined line, not one line per field; saving a form with no actual changes (an Edit dialog opened and immediately saved) now writes nothing at all, instead of the previous generic entry firing unconditionally. Primary fields (name, domain/aliases, target, TLS, HSTS, Access List, health-check on/off, and per-type fields like redirect code/path-preservation or streaming port/protocol) are called out individually; the long tail of advanced settings (custom headers, custom Caddy config, load-balancing upstreams/policy, upstream TLS overrides, and the fine-grained health-check parameters) are bucketed into a single "advanced settings updated" line to keep the summary readable rather than enumerating every possible field.
`v0.16.67` converts the Administration -> Users -> Audit log tab from a card-row list (`.event-row` divs with a checkmark/bang `.activity-mark`) to the same pinned-header table every other log-type page now uses -- Gateway Events, Access Logs, and Backup history (v0.16.63). It was the last page still on the older list pattern, noticed immediately after v0.16.66 added several new distinct audit entries and made the mismatch obvious side by side with Gateway Events. The table has four columns -- Time, User, Result, Action -- reusing the exact `performance-table`/`event-table` CSS and the standard green/red `status-dot` convention in place of the old custom `.activity-mark` dot. No backend or data changes; `/api/audit` already returned everything the columns needed. The now-orphaned `#audit-list` div styling (border, background, max-height, the bespoke activity-mark colors) is removed along with it, since the table's own wrapper classes already provide the equivalent scrolling/sticky-header container.
`v0.16.68` is a quick follow-on to v0.16.67: the Audit log's new table had no surrounding card, so it sat flush against the tab content instead of appearing inside the same bordered, elevated "tile" every other tab-hosted table uses -- Backup history's `dashboard-panel backup-history-section` wrapper on the Backups tab being the closest match. The Audit log's table markup is now wrapped in an equivalent `dashboard-panel audit-log-section` with an "Audit log" eyebrow above the existing heading, matching Backup history's structure exactly (search/filter row and table unchanged). Purely a wrapper/markup change -- no new CSS, no data or behavior changes.
`v0.16.69` closes a gap flagged after reviewing the ZimaOS compose file: `compose.yaml`, `compose.release.yaml`, and `compose.zimaos.yaml` all documented `DATA_DIR_LIMIT_GB` and the `mem_limit`/`cpus`/`cpuset` resource-panel options in README/install.html but never actually carried them as commented-optional entries the way the marketing site's install guide showed -- someone copying a real compose file instead of the docs page got none of that guidance. All three now include the same commented-out block (env-var style in the two static compose files, `${VAR}`-substituted and wired through `.env.example` in `compose.release.yaml`). This also folds in the marketing site's `install.html`, which had drifted out of sync with an already-updated draft and was missing the same options on the user's machine -- resynced so the published guide matches what ships in the repo.
`v0.16.70` fixes the Access Logs table (Administration > Logs, "Access requests"), the last page still on its own pre-unification CSS: `.log-table` pinned the Status and Duration columns with `position:sticky` and drew a divider `box-shadow` on each -- a horizontal-scroll affordance none of the other log-type pages use -- and left the Request column at a fixed width instead of stretching to fill the panel, so wide viewports showed an empty gap past Duration. Both are removed: the two trailing columns are back in normal table flow, and Request now takes `width:auto` to absorb the remaining space, matching how Gateway Events, Backup history, and the Audit log already size their last column. Purely a CSS change -- no markup, data, or behavior changes.
`v0.16.71` fixes a Gateway Events / Audit log message that slipped past v0.16.66: the Hosted Site file-upload route ("Replace files") still wrote the old, un-prefixed `Files replaced for "<name>".` line instead of the `Hosted site "<name>" ...` convention every other Hosted Site action uses (created, enabled/disabled, deleted, and the field-level update summaries). It lived in its own route separate from the four `PATCH` handlers v0.16.66 touched, so it was missed at the time and only noticed once a user pointed out the log entry gave no way to tell which route type it belonged to. Now reads `Hosted site "<name>" files replaced.`, matching the rest.
`v0.16.72` fixes the favicon not appearing in some Chromium-family browsers (reported: DuckDuckGo's browser showed the default globe icon while Safari showed the real one correctly). The app previously declared a single `<link rel="icon">` pointing straight at the 1018x1001, 396 KB source PNG with no `sizes` attribute and no `/favicon.ico` fallback -- Safari is forgiving about oversized, unsized favicons, but some Chromium-based browsers silently skip one that large rather than downscale it, and several also probe `/favicon.ico` directly regardless of what the `<link>` tag says. Added properly sized `favicon.ico` (16/32px, multi-size), standalone `favicon-16.png`/`favicon-32.png` with `sizes` attributes, and a 180x180 `apple-touch-icon.png`, all generated from the existing approved icon artwork and served automatically by the existing static file handler -- no server route or icon design changes.
`v0.16.73` makes the Dashboard's System panel top accent bar reflect resource state, instead of always showing green -- pointed out after a screenshot showed CPU pinned at 100% (its own stat correctly shown in red) while the panel's top bar stayed the hardcoded `var(--green)` it always had. `renderHeroPanel()` now tracks the worst tone across CPU/memory/swap/disk (the same "warning" at 75%+ / "critical" at 90%+ thresholds each stat's own value and fill bar already used) and applies a `tone-warning`/`tone-critical` class to the panel, which `.system-panel::before` now reads instead of a fixed color. Network, uptime, and throughput don't carry a tone and are excluded from the calculation, same as before. Applies to the Dashboard's System panel only -- the Administration > System tab's equivalent hero grid has no top accent bar to react.
`v0.16.74` fixes an inaccurate status label on Hosted Site, Proxy Host, and Streaming Host cards, and the Certificates table's Upstream column: a route with monitoring intentionally turned off via "Monitor this site/upstream" in Advanced options -- while the route itself stays enabled and running -- read "Monitoring paused", the same text used for a route that's fully disabled. "Paused" implies a temporary interruption; deliberately unchecking the monitor box is an ongoing, intentional setting. The backend already distinguished the two cases (`status: "disabled"` when the route itself is off vs. `status: "unmonitored"` when only health checks are off, in `checkProxy()`), the frontend just collapsed them into one string in four places. Disabled routes keep "Monitoring paused"; a running route with health checks off now reads "Monitoring disabled". No backend or status-logic changes, text only.
`v0.16.75` is a one-line smoke test to confirm the Gitea remote (`git.us2plus2.com/marvin/site-gateway`) actually receives pushes end-to-end after the GitHub-to-Gitea migration: the Dashboard heading text reads "Dashboard v2" instead of "Dashboard" (both the static HTML fallback and the JS that sets it on render), with no other functional change. Pushed to both `origin` (Gitea) and `github` remotes per the dual-push arrangement while GitHub CI still builds the published container image.
`v0.16.76` fixes the Gitea Actions container-publish pipeline, which failed twice after the earlier GitHub-to-Gitea migration -- first at registry sign-in (missing `REGISTRY_USERNAME`/`REGISTRY_TOKEN` repo secrets, added directly in Gitea's Actions settings, no workflow change needed) and then at the "Scan image for vulnerabilities" step, which used `aquasecurity/trivy-action@0.35.0`. That action installs Trivy at runtime via a `git clone`-based installer script against GitHub, a dependency separate from the registry sign-in fix and one that doesn't reliably resolve from a Gitea Actions runner. Replaced with two plain shell steps: a pinned `curl` of Trivy 0.56.2's release tarball directly from GitHub's release CDN (a single static HTTPS download, not a git checkout) followed by `trivy image` run directly, keeping the same severity/exit-code/format settings. Scanning behavior is unchanged; only the installation mechanism moved off the flaky git-based installer.
`v0.16.77` fixes a follow-on to v0.16.76's Trivy-installer replacement: the pinned version, `0.56.2`, no longer exists on GitHub's release list (current is `v0.74.0`), so the `curl -sfL` request 404'd and failed fast with exit code 22 instead of installing anything. `TRIVY_VERSION` is now `0.74.0`, verified against the actual release assets before pushing. No other change to the install/scan steps from v0.16.76.
`v0.16.78` restores GitHub's half of the dual-publish pipeline without touching Gitea's: the earlier migration commits had rewritten `container.yml`'s registry login, tag, and publish steps to target only `git.us2plus2.com` using Gitea-only secrets, so every push to GitHub since then failed in ~30 seconds at "Sign in to Gitea Container Registry" with "Username and password required" -- GitHub's repo never had `REGISTRY_USERNAME`/`REGISTRY_TOKEN`. The login, tag-generation, and publish steps are now duplicated, one set per registry, each gated with `if: github.server_url == 'https://github.com'` (or `!=`) so the workflow self-selects which registry to sign into and push to depending on which host is actually running it -- Gitea Actions keeps using the existing `REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets against `git.us2plus2.com` exactly as before, GitHub Actions goes back to `ghcr.io` using `github.actor`/`GITHUB_TOKEN` as it did pre-migration. The shared build/scan steps (smoke-test image, SQLite check, Trivy install/scan) are unconditional and run identically on both. Also reverts the v0.16.75 smoke-test change: the Dashboard heading text is back to "Dashboard" now that the Gitea pipeline is confirmed working end to end.
+12 -1
View File
@@ -5,7 +5,7 @@
# docker compose -f compose.release.yaml up -d # docker compose -f compose.release.yaml up -d
services: services:
site-gateway: site-gateway:
image: ${SITE_GATEWAY_IMAGE:-ghcr.io/mfwadejr/site-gateway2:latest} image: ${SITE_GATEWAY_IMAGE:-git.us2plus2.com/marvin/site-gateway:latest}
container_name: site-gateway container_name: site-gateway
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -20,6 +20,17 @@ services:
PUID: ${PUID:-1000} PUID: ${PUID:-1000}
PGID: ${PGID:-1000} PGID: ${PGID:-1000}
ACME_EMAIL: ${ACME_EMAIL:-} ACME_EMAIL: ${ACME_EMAIL:-}
# Optional: display-only Disk allowance for the resource panel. Set
# DATA_DIR_LIMIT_GB in .env to use.
DATA_DIR_LIMIT_GB: ${DATA_DIR_LIMIT_GB:-}
# Optional but recommended: without a memory/CPU limit, the Dashboard and
# Administration > System tab's live resource panel can only show usage
# against the whole host. Uncomment and set in .env to give CPU/Memory a
# real, container-scoped denominator (MEM_LIMIT, e.g. "2g"; CPU_LIMIT,
# e.g. "2"; or CPUSET, e.g. "0,1", to pin cores instead of a count).
# mem_limit: ${MEM_LIMIT}
# cpus: ${CPU_LIMIT}
# cpuset: ${CPUSET}
ports: ports:
- "${HTTP_PORT:-80}:80" - "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443" - "${HTTPS_PORT:-443}:443"
+14
View File
@@ -43,6 +43,20 @@ services:
# Optional: certificate account email, passed to Caddy's ACME client. # Optional: certificate account email, passed to Caddy's ACME client.
ACME_EMAIL: "" ACME_EMAIL: ""
# Optional: display-only Disk allowance for the resource panel (e.g. a
# smaller dedicated share) -- usage/free space still come from the real
# volume, this just gives the panel a number to measure against.
# DATA_DIR_LIMIT_GB: 30
# Optional but recommended: without a memory/CPU limit, the Dashboard and
# Administration > System tab's live resource panel can only show usage
# against the whole host, which is rarely meaningful on a shared machine.
# Setting these gives CPU/Memory a real, container-scoped denominator.
# mem_limit: 2g
# cpus: "2"
# Pinning specific cores is also supported instead of (or alongside) a
# count -- the resource panel reads whichever one Docker actually applied.
# cpuset: "0,1"
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
+22 -7
View File
@@ -13,7 +13,7 @@ name: site-gateway
services: services:
site-gateway: site-gateway:
image: ghcr.io/mfwadejr/site-gateway2:latest image: git.us2plus2.com/marvin/site-gateway:latest
container_name: site-gateway container_name: site-gateway
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -30,6 +30,21 @@ services:
PUID: 1000 PUID: 1000
PGID: 1000 PGID: 1000
ACME_EMAIL: "" ACME_EMAIL: ""
# Optional: display-only Disk allowance for the resource panel (e.g. a
# smaller dedicated share) -- usage/free space still come from the real
# volume, this just gives the panel a number to measure against.
# DATA_DIR_LIMIT_GB: 30
# Optional but recommended: without a memory/CPU limit, the Dashboard and
# Administration > System tab's live resource panel can only show usage
# against the whole host, which is rarely meaningful on a shared ZimaOS
# box. Setting these gives CPU/Memory a real, container-scoped
# denominator instead.
# mem_limit: 2g
# cpus: "2"
# Pinning specific cores is also supported instead of (or alongside) a
# count -- the resource panel reads whichever one Docker actually applied.
# cpuset: "0,1"
ports: ports:
- target: 8080 - target: 8080
published: "8080" published: "8080"
@@ -62,7 +77,7 @@ x-casaos:
index: / index: /
port_map: "8080" port_map: "8080"
scheme: http scheme: http
icon: https://raw.githubusercontent.com/mfwadejr/site-gateway2/main/src/public/site-gateway-icon-approved.png icon: https://git.us2plus2.com/marvin/site-gateway/raw/branch/main/src/public/site-gateway-icon-approved.png
title: title:
en_US: Site Gateway en_US: Site Gateway
tagline: tagline:
@@ -76,8 +91,8 @@ x-casaos:
author: mfwadejr author: mfwadejr
developer: mfwadejr developer: mfwadejr
architectures: ["amd64", "arm64"] architectures: ["amd64", "arm64"]
version: "0.11.101" version: "0.16.58"
update_at: "2026-09-16" update_at: "2026-09-20"
website: https://github.com/mfwadejr/site-gateway2 website: https://git.us2plus2.com/marvin/site-gateway
repo: https://github.com/mfwadejr/site-gateway2 repo: https://git.us2plus2.com/marvin/site-gateway
support: https://github.com/mfwadejr/site-gateway2/issues support: https://git.us2plus2.com/marvin/site-gateway/issues
+43 -1
View File
@@ -29,9 +29,51 @@ fi
export XDG_DATA_HOME="${DATA_DIR:-/data}/certificates/managed" export XDG_DATA_HOME="${DATA_DIR:-/data}/certificates/managed"
export XDG_CONFIG_HOME="${DATA_DIR:-/data}/caddy/config" 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 & su-exec "$app_uid:$app_gid" caddy run --config "$caddyfile" --adapter caddyfile &
caddy_pid=$! caddy_pid=$!
su-exec "$app_uid:$app_gid" "$@" & su-exec "$app_exec_target" "$@" &
app_pid=$! app_pid=$!
shutdown() { shutdown() {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "site-gateway", "name": "site-gateway",
"version": "0.16.3", "version": "0.16.78",
"private": true, "private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module", "type": "module",
+183 -71
View File
@@ -8,7 +8,7 @@
// --- Shared DOM shortcut and app state ---------------------------------------- // --- Shared DOM shortcut and app state ----------------------------------------
const $ = selector => document.querySelector(selector); const $ = selector => document.querySelector(selector);
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] }; const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], usersLoaded: false, user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] };
// One-time DOM patches: move the Access List field into the create/settings // One-time DOM patches: move the Access List field into the create/settings
// forms (features.js owns the Access List data, this file owns these forms). // forms (features.js owns the Access List data, this file owns these forms).
@@ -29,7 +29,7 @@ async function api(url, options = {}) {
} }
// --- Login/dashboard shell, toast, and small formatting helpers ------------------ // --- Login/dashboard shell, toast, and small formatting helpers ------------------
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); } function showLogin(message = "") { state.user = null; state.users = []; state.usersLoaded = false; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); }
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); } function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
function toast(message, type = "success") { const el = $("#toast"); el.textContent = message; el.classList.toggle("toast-error", type === "error"); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); } function toast(message, type = "success") { const el = $("#toast"); el.textContent = message; el.classList.toggle("toast-error", type === "error"); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; } function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
@@ -94,7 +94,7 @@ function advancedFormBody(form, body, scoped) {
const read = (name, fallback = "") => scoped ? scopedValue(scoped.formEl, scoped.scope, name, fallback) : (form.get(name) || fallback); const read = (name, fallback = "") => scoped ? scopedValue(scoped.formEl, scoped.scope, name, fallback) : (form.get(name) || fallback);
const checked = (name) => scoped ? Boolean(scoped.formEl.querySelector(`${scoped.scope} [name="${name}"]`)?.checked) : form.has(name); const checked = (name) => scoped ? Boolean(scoped.formEl.querySelector(`${scoped.scope} [name="${name}"]`)?.checked) : form.has(name);
body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure"); body.blockCommonExploits = checked("blockCommonExploits"); body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure");
body.accessListId = read("accessListId", body.accessListId || ""); body.accessListId = read("accessListId", body.accessListId || "");
body.requestHeaders = parseHeaderLines(read("requestHeadersText")); body.responseHeaders = parseHeaderLines(read("responseHeadersText")); body.compression = read("compression", "automatic"); body.customConfig = read("customConfig"); body.requestHeaders = parseHeaderLines(read("requestHeadersText")); body.responseHeaders = parseHeaderLines(read("responseHeadersText")); body.compression = read("compression", "automatic"); body.customConfig = read("customConfig");
body.locations = String(form.get("customLocationsText") || "").split("\n").map(line => { const [path, target, behavior] = line.split("|").map(value => value.trim()); return path && target ? { path, target, stripPrefix:behavior.toLowerCase() === "strip" } : null; }).filter(Boolean); body.locations = String(form.get("customLocationsText") || "").split("\n").map(line => { const [path, target, behavior] = line.split("|").map(value => value.trim()); return path && target ? { path, target, stripPrefix:behavior.toLowerCase() === "strip" } : null; }).filter(Boolean);
@@ -123,7 +123,7 @@ document.addEventListener("submit", async event => {
try { try {
await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); } if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); }
$("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); $("#settings-dialog").close(); await refreshCurrentView(); toast("Gateway settings applied.");
} }
catch (error) { $("#settings-error").textContent = error.message; } catch (error) { $("#settings-error").textContent = error.message; }
finally { button.disabled = false; } finally { button.disabled = false; }
@@ -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 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 // Dashboard tiles share one baseline accent (green) and switch to the existing
// --warning / --danger tokens when the thing they count is actually in trouble -- // --warning / --danger tokens when the thing they count is actually in trouble --
// the same mechanism the "Needs attention" chip already used. // the same mechanism the "Needs attention" chip already used.
@@ -182,7 +181,7 @@ function renderDashboard() {
$("#dash-hosted-detail").textContent = healthCopy(data.hosted, "sites"); $("#dash-hosted-detail").textContent = healthCopy(data.hosted, "sites");
$("#dash-proxy-total").textContent = data.proxies.total; $("#dash-proxy-total").textContent = data.proxies.total;
$("#dash-proxy-detail").textContent = healthCopy(data.proxies, "routes"); $("#dash-proxy-detail").textContent = healthCopy(data.proxies, "routes");
$("#dash-tls-total").textContent = data.tlsDomains; $("#dash-tls-total").textContent = data.certificates.total;
$("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains"; $("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains";
$("#dash-redirect-total").textContent = state.redirects?.length || 0; $("#dash-redirect-total").textContent = state.redirects?.length || 0;
$("#dash-stream-total").textContent = state.streams?.length || 0; $("#dash-stream-total").textContent = state.streams?.length || 0;
@@ -214,27 +213,19 @@ function renderDashboard() {
$("#upstream-health-dot").className = `status-dot ${!upstreams.total ? "inactive" : upstreams.unhealthy > 0 ? "error" : "running"}`; $("#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`; $("#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)}`; $("#health-checked").innerHTML = `<span class="live-dot" id="health-live-dot"></span>Last checked ${formatTime(data.checkedAt)}`;
updateDashboardUptime(data.system.uptimeSeconds); // Memory/Data/Storage/Version/Database/Public IP moved to the Administration > System tab's
$("#system-memory").textContent = formatBytes(data.system.memoryBytes); // Version panel -- the Dashboard's own Runtime/System panel is now the shared hero component
$("#system-data").textContent = formatBytes(data.system.dataBytes); // (see renderHeroPanel/refreshDashboardHero), which reads real container-scoped CPU/memory/
$("#system-disk").textContent = formatBytes(data.system.diskFreeBytes); // swap/disk/network from /api/system/health instead of this endpoint's coarser numbers.
$("#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";
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0); $("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
$("#dashboard-lower-columns").classList.toggle("attention-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" $("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => item.kind === "drift"
? `<div class="attention-tile drift-tile"><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span><button type="button" class="button secondary" data-drift-resync>Resync now</button></div>` ? `<div class="attention-tile drift-tile"><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span>${canAdmin() ? '<button type="button" class="button secondary" data-drift-resync>Resync now</button>' : ""}</div>`
: `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>` : `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`
).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>'; ).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
$("#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>'; $("#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) ----------------------------------- // --- Card rendering helpers (icons, permissions) -----------------------------------
@@ -250,20 +241,24 @@ function canAdmin() { return state.user?.role === "administrator"; }
// --- Hosted Sites & Proxy Hosts: card templates ------------------------------------ // --- Hosted Sites & Proxy Hosts: card templates ------------------------------------
function upstreamStateClass(enabled, upstream) {
if (!enabled || upstream?.status === "unmonitored" || !upstream || upstream.status === "pending") return "idle";
return upstream.status === "healthy" ? "" : "bad";
}
function hostedCard(site) { function hostedCard(site) {
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled"; const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`; const upstream = !site.enabled ? "Monitoring paused" : site.upstream?.status === "unmonitored" ? "Monitoring disabled" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : ""; const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : ""; const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : "";
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${site.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`; return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${upstreamStateClass(site.enabled, site.upstream)}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
} }
function proxyCard(proxy) { function proxyCard(proxy) {
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled"; const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`; const upstream = !proxy.enabled ? "Monitoring paused" : proxy.upstream?.status === "unmonitored" ? "Monitoring disabled" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : ""; const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : ""; const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : "";
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List"; const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`; return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${upstreamStateClass(proxy.enabled, proxy.upstream)}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
} }
@@ -274,26 +269,36 @@ function renderCertificates() {
$("#cert-healthy").textContent = data.summary.healthy; $("#cert-30").textContent = data.summary.within30Days; $("#cert-7").textContent = data.summary.within7Days; $("#cert-warning").textContent = data.summary.warning + data.summary.critical + data.summary.expired + data.summary.mismatch; $("#cert-pending").textContent = data.summary.pending; $("#cert-healthy").textContent = data.summary.healthy; $("#cert-30").textContent = data.summary.within30Days; $("#cert-7").textContent = data.summary.within7Days; $("#cert-warning").textContent = data.summary.warning + data.summary.critical + data.summary.expired + data.summary.mismatch; $("#cert-pending").textContent = data.summary.pending;
const ageMinutes = (Date.now() - new Date(data.checkedAt).getTime()) / 60000, stale = ageMinutes > (data.thresholds?.staleMinutes || 10); const ageMinutes = (Date.now() - new Date(data.checkedAt).getTime()) / 60000, stale = ageMinutes > (data.thresholds?.staleMinutes || 10);
$("#cert-last-checked").textContent = `Last checked ${formatTime(data.checkedAt)} · ${stale ? "data may be stale" : "current"}`; $("#cert-last-checked").textContent = `Last checked ${formatTime(data.checkedAt)} · ${stale ? "data may be stale" : "current"}`;
$("#certificate-list").innerHTML = data.certificates.length ? data.certificates.map(cert => `<details class="certificate-row"><summary><span class="status-dot ${cert.status === "healthy" ? "running" : cert.status === "pending" ? "idle" : "error"}"></span><span><strong>${escapeHtml(cert.domain)}</strong><small>${escapeHtml(cert.kind)} · ${escapeHtml(cert.name)} · ${escapeHtml(cert.source)}</small></span><span><strong>${cert.expiresAt ? `${cert.daysRemaining} days remaining` : cert.status === "mismatch" ? "Domain mismatch" : "Not detected"}</strong><small>${cert.expiresAt ? `Expires ${formatTime(cert.expiresAt)}` : cert.mismatch ? `Covers: ${(cert.coveredNames || []).map(escapeHtml).join(", ") || "no DNS names"}` : "No stored certificate was found"}</small></span></summary><dl class="certificate-details"><div><dt>Status</dt><dd>${escapeHtml(cert.status)}</dd></div><div><dt>Valid from</dt><dd>${cert.validFrom ? escapeHtml(formatTime(cert.validFrom)) : "—"}</dd></div><div><dt>Issuer</dt><dd>${escapeHtml(cert.issuer || "—")}</dd></div><div><dt>Covered domains</dt><dd>${escapeHtml((cert.coveredNames || []).join(", ") || "—")}</dd></div><div><dt>Serial number</dt><dd>${escapeHtml(cert.serialNumber || "—")}</dd></div><div><dt>SHA-256 fingerprint</dt><dd>${escapeHtml(cert.fingerprint || "—")}</dd></div><div><dt>Last detected update</dt><dd>${cert.updatedAt ? escapeHtml(formatTime(cert.updatedAt)) : "—"}</dd></div></dl></details>`).join("") : '<p class="quiet-state padded">No HTTPS domains are configured.</p>';
renderReadiness();
}
// --- Domain readiness (used inside the Certificates view) ---------------------------
function renderReadiness() {
const routes = state.readiness?.routes || []; const routes = state.readiness?.routes || [];
$("#readiness-list").innerHTML = routes.length ? routes.map(item => { state.certRows = data.certificates.map(cert => ({ cert, readiness: routes.find(item => item.domain === cert.domain) || null }));
const dnsOk = item.dns.healthy, portsOk = item.ports.http && item.ports.https !== false; $("#certificate-list").innerHTML = state.certRows.length ? state.certRows.map((row, index) => {
const tlsOk = ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status); const cert = row.cert, item = row.readiness;
const upstreamOk = !item.upstream || item.upstream.status === "healthy"; const dnsOk = item ? item.dns.healthy : null;
const check = item.upstream; const tlsOk = item ? ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status) : null;
const message = !dnsOk ? `DNS failed${item.dns.error ? ` · ${item.dns.error}` : ""}` : !item.ports.http ? "HTTP port 80 is not responding inside the container" : item.ports.https === false ? "HTTPS port 443 is not responding inside the container" : !tlsOk ? `TLS ${item.tls.status.replaceAll("-", " ")}` : !upstreamOk ? `Upstream ${check?.error || "unavailable"}` : `Ready · DNS ${item.dns.addresses.join(", ")}${check ? ` · upstream ${check.httpStatus || "responding"}` : ""}`; const dnsCell = item ? `<span class="status-dot ${dnsOk ? "running" : "error"}"></span>${dnsOk ? "Resolved" : "Failed"}` : `<span class="status-dot idle"></span>—`;
const upstreamDetail = check ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${check.httpStatus ?? "no response"}${check.responseMs != null ? ` · ${check.responseMs} ms` : ""} · ${check.attempts || 1} attempt${(check.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(check.checkedAt))}</dd></div>${check.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(check.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>"; const tlsCell = item ? `<span class="status-dot ${tlsOk ? "running" : "error"}"></span>${escapeHtml(item.tls.status.replaceAll("-", " "))}` : `<span class="status-dot idle"></span>—`;
return `<details class="certificate-row readiness-row"><summary><span class="status-dot ${dnsOk && portsOk && tlsOk && upstreamOk ? "running" : "error"}"></span><span><strong>${escapeHtml(item.domain)}</strong><small>${escapeHtml(message)}</small></span></summary><dl class="certificate-details"><div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${upstreamDetail}</dl></details>`; const upstreamCell = !item ? `<span class="status-dot idle"></span>—` : !item.upstream ? `<span class="status-dot idle"></span>Monitoring paused` : item.upstream.status === "unmonitored" ? `<span class="status-dot idle"></span>Monitoring disabled` : item.upstream.status === "pending" ? `<span class="status-dot idle"></span>Check pending` : item.upstream.status === "healthy" ? `<span class="status-dot running"></span>${item.upstream.httpStatus}` : `<span class="status-dot bad"></span>${escapeHtml(item.upstream.error || "Unavailable")}`;
}).join("") : '<p class="quiet-state">No configured domains to check.</p>'; const statusLabel = cert.status === "mismatch" ? "Domain mismatch" : cert.status.charAt(0).toUpperCase() + cert.status.slice(1);
return `<tr class="cert-table-row" data-index="${index}" tabindex="0"><td><strong>${escapeHtml(cert.domain)}</strong><br><small class="muted">${escapeHtml(cert.kind)} · ${escapeHtml(cert.source)}</small></td><td><span class="status-dot ${cert.status === "healthy" ? "running" : cert.status === "pending" ? "idle" : "error"}"></span>${escapeHtml(statusLabel)}</td><td>${cert.expiresAt ? `${cert.daysRemaining} days` : "—"}</td><td>${escapeHtml(cert.issuer || "—")}</td><td>${dnsCell}</td><td>${tlsCell}</td><td>${upstreamCell}</td></tr>`;
}).join("") : '<tr><td colspan="7" class="quiet-state">No HTTPS domains are configured.</td></tr>';
} }
// --- Certificate detail popup (deep fields for a single certificate row) ------------
function openCertificateDetail(row) {
const cert = row.cert, item = row.readiness;
$("#cert-detail-title").textContent = cert.domain;
$("#cert-detail-eyebrow").textContent = `${cert.kind} · ${cert.source}`;
const certRows = `<div><dt>Status</dt><dd>${escapeHtml(cert.status)}</dd></div><div><dt>Valid from</dt><dd>${cert.validFrom ? escapeHtml(formatTime(cert.validFrom)) : "—"}</dd></div><div><dt>Expires</dt><dd>${cert.expiresAt ? escapeHtml(formatTime(cert.expiresAt)) : "—"}</dd></div><div><dt>Issuer</dt><dd>${escapeHtml(cert.issuer || "—")}</dd></div><div><dt>Covered domains</dt><dd>${escapeHtml((cert.coveredNames || []).join(", ") || "—")}</dd></div><div><dt>Serial number</dt><dd>${escapeHtml(cert.serialNumber || "—")}</dd></div><div><dt>SHA-256 fingerprint</dt><dd>${escapeHtml(cert.fingerprint || "—")}</dd></div><div><dt>Last detected update</dt><dd>${cert.updatedAt ? escapeHtml(formatTime(cert.updatedAt)) : "—"}</dd></div>`;
const readinessRows = item ? `<div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${item.upstream ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${item.upstream.httpStatus ?? "no response"}${item.upstream.responseMs != null ? ` · ${item.upstream.responseMs} ms` : ""} · ${item.upstream.attempts || 1} attempt${(item.upstream.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(item.upstream.checkedAt))}</dd></div>${item.upstream.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(item.upstream.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>"}` : "<div><dt>Domain readiness</dt><dd>No readiness data available for this domain.</dd></div>";
$("#cert-detail-body").innerHTML = certRows + readinessRows;
$("#certificate-detail-dialog").showModal();
}
$("#certificate-list").addEventListener("click", event => { const row = event.target.closest(".cert-table-row"); if (!row) return; const data = state.certRows?.[Number(row.dataset.index)]; if (data) openCertificateDetail(data); });
$("#certificate-list").addEventListener("keydown", event => { if (event.key !== "Enter" && event.key !== " ") return; const row = event.target.closest(".cert-table-row"); if (!row) return; event.preventDefault(); const data = state.certRows?.[Number(row.dataset.index)]; if (data) openCertificateDetail(data); });
$("#cert-threshold-trigger").addEventListener("click", () => { renderHealthSettings(); $("#health-settings-dialog").showModal(); });
// --- Logs view ------------------------------------------------------------------------- // --- Logs view -------------------------------------------------------------------------
function renderLogs() { function renderLogs() {
const data = state.logs; if (!data) return; const data = state.logs; if (!data) return;
@@ -305,7 +310,7 @@ function renderLogs() {
const categoryOf = message => /cert|tls|https/i.test(message) ? "certificate" : /health|upstream|response|fetch/i.test(message) ? "health" : /login|user|password|access/i.test(message) ? "authentication" : /backup|restore/i.test(message) ? "backup" : /config|route|host|gateway|reload/i.test(message) ? "configuration" : "system"; const categoryOf = message => /cert|tls|https/i.test(message) ? "certificate" : /health|upstream|response|fetch/i.test(message) ? "health" : /login|user|password|access/i.test(message) ? "authentication" : /backup|restore/i.test(message) ? "backup" : /config|route|host|gateway|reload/i.test(message) ? "configuration" : "system";
const severity = $("#event-severity").value, category = $("#event-category").value; const severity = $("#event-severity").value, category = $("#event-category").value;
const activity = data.activity.filter(item => (!severity || item.status === severity) && (!category || categoryOf(item.message) === category)); const activity = data.activity.filter(item => (!severity || item.status === severity) && (!category || categoryOf(item.message) === category));
$("#gateway-log-list").innerHTML = activity.length ? activity.map(item => { const eventCategory = categoryOf(item.message); const indicatorClass = item.status === "error" ? "disabled" : item.status === "warning" ? "error" : "running"; return `<div class="event-row"><span class="status-dot ${indicatorClass}" aria-label="${escapeHtml(item.status || "ok")}"></span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(eventCategory)} · ${escapeHtml(formatTime(item.at))}</small></span></div>`; }).join("") : '<div class="gateway-empty-state"><span class="status-dot"></span><strong>No matching gateway events</strong><small>Try a different severity or category filter.</small></div>'; $("#gateway-log-list").innerHTML = activity.length ? activity.map(item => { const eventCategory = categoryOf(item.message); const indicatorClass = item.status === "error" ? "disabled" : item.status === "warning" ? "error" : "running"; const severityLabel = item.status === "error" ? "Error" : item.status === "warning" ? "Warning" : "Normal"; return `<tr><td>${escapeHtml(formatTime(item.at))}</td><td><span class="status-dot ${indicatorClass}"></span>${severityLabel}</td><td>${escapeHtml(eventCategory)}</td><td>${escapeHtml(item.message)}</td></tr>`; }).join("") : '<tr><td colspan="4" class="quiet-state">No matching gateway events. Try a different severity or category filter.</td></tr>';
} }
@@ -334,7 +339,6 @@ function renderPerformance() {
$("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`; $("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`;
const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours"; const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`; $("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
$("#performance-slowest-title").textContent = `Slowest requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
const points = data.trend || []; const points = data.trend || [];
state.performancePoints = points; state.performancePoints = points;
const max = Math.max(1, ...points.map(point => point.count)); const max = Math.max(1, ...points.map(point => point.count));
@@ -394,17 +398,16 @@ function renderPerformance() {
const routes = (data.routes || []).filter(route => !selected || route.host === selected); const routes = (data.routes || []).filter(route => !selected || route.host === selected);
state.performanceErrorBreakdowns = {}; state.performanceErrorBreakdowns = {};
state.performanceTopPaths = {}; state.performanceTopPaths = {};
const countCell = (count, errors, breakdown, host) => { if (!errors) return `${count.toLocaleString()}`; if (!breakdown?.length) return `${count.toLocaleString()} <span class="count-divider">·</span> <span class="http-status bad">${errors.toLocaleString()}</span>`; state.performanceErrorBreakdowns[host] = { total: errors, breakdown }; return `${count.toLocaleString()} <span class="count-divider">·</span> <button type="button" class="http-status bad count-link-button" data-error-host="${escapeHtml(host)}">${errors.toLocaleString()}</button>`; }; const countCell = count => `${count.toLocaleString()}`;
const pathsCell = route => { if (!route.topPaths?.length) return "—"; state.performanceTopPaths[route.host] = route.topPaths; return `<button type="button" class="count-link-button neutral" data-paths-host="${escapeHtml(route.host)}">View</button>`; }; const pathsCell = route => { if (!route.topPaths?.length) return "—"; state.performanceTopPaths[route.host] = route.topPaths; return `<button type="button" class="count-link-button neutral" data-paths-host="${escapeHtml(route.host)}">View</button>`; };
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}</td><td>${(route.dayVisitors || 0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`).join("") : '<tr><td colspan="8" class="quiet-state">No requests have been logged yet.</td></tr>'; // Requests are logged for any Host header Caddy ever saw, including ones with no matching
// Hosted Site / Proxy Host / Redirect Host -- those fall through to the Default Site handler
// instead of a real backend. Badge those rows so they read as log history, not live config.
const configuredDomains = new Set([...state.sites, ...state.proxies, ...state.redirects].flatMap(item => [item.domain, ...(item.domains || [])]).filter(Boolean).map(domain => domain.toLowerCase()));
const isUnconfigured = route => !configuredDomains.has((route.host || "").toLowerCase());
const visibleRoutes = state.performanceHideUnconfigured ? routes.filter(route => !isUnconfigured(route)) : routes;
$("#performance-rows").innerHTML = visibleRoutes.length ? visibleRoutes.map(route => { const unconfigured = isUnconfigured(route); return `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}${unconfigured ? ' <span class="chip unconfigured-chip" title="No Hosted Site, Proxy Host, or Redirect Host currently matches this domain -- these requests hit the Default Site handler instead of a real backend.">Not configured</span>' : ""}</td><td>${countCell(route.hourRequests)}</td><td>${countCell(route.dayRequests)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}</td><td>${(route.dayVisitors || 0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`; }).join("") : `<tr><td colspan="8" class="quiet-state">${routes.length ? "No configured domains match the current filter — uncheck \u201cHide not configured\u201d to see them." : "No requests have been logged yet."}</td></tr>`;
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" }); if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
renderSlowestRequests(data.slowest || []);
}
// --- Performance: slowest individual requests -------------------------------------------
function renderSlowestRequests(entries) {
const list = $("#performance-slowest"); if (!list) return;
list.innerHTML = entries.length ? entries.map(entry => `<div class="activity-tile"><span class="slowest-copy"><strong title="${escapeHtml(`${entry.method || ""} ${entry.uri || ""}`)}">${escapeHtml(entry.method || "GET")} ${escapeHtml(entry.uri || "/")}</strong><small>${escapeHtml(entry.host || "—")} · ${entry.status ?? "—"} · ${escapeHtml(formatTime(entry.at))}</small></span><span class="slowest-duration">${escapeHtml(formatLatency(entry.durationMs))}</span></div>`).join("") : '<p class="quiet-state">No timed requests in this window yet.</p>';
} }
// --- Performance: hover tooltip on the request-trend chart ------------------------------- // --- Performance: hover tooltip on the request-trend chart -------------------------------
@@ -459,7 +462,7 @@ function renderUsers() {
const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`; const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`;
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" type="button" aria-label="User options" aria-expanded="false">•••</button><div class="menu"><button data-user-action="icon">Change icon</button>${!isSelf && user.mfaEnabled ? `<button data-user-action="mfa-disable">Disable 2FA</button>` : ""}${!isSelf ? `<button data-user-action="delete" class="danger-text">Delete</button>` : ""}</div></div>`; const menu = `<div class="menu-wrap"><button class="icon-button menu-button" type="button" aria-label="User options" aria-expanded="false">•••</button><div class="menu"><button data-user-action="icon">Change icon</button>${!isSelf && user.mfaEnabled ? `<button data-user-action="mfa-disable">Disable 2FA</button>` : ""}${!isSelf ? `<button data-user-action="delete" class="danger-text">Delete</button>` : ""}</div></div>`;
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><div class="user-head-actions"><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span>${menu}</div></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}</div><div class="card-footer">${statusToggle}</div></article>`; return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><div class="user-head-actions"><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span>${menu}</div></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}</div><div class="card-footer">${statusToggle}</div></article>`;
}).join("") : '<p class="quiet-state">No users found.</p>'; }).join("") : state.usersLoaded ? '<p class="quiet-state">No users found.</p>' : '<p class="quiet-state">Loading users…</p>';
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); }); document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); });
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message, "error"); } }); old.replaceWith(select); }); document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message, "error"); } }); old.replaceWith(select); });
} }
@@ -485,7 +488,7 @@ async function loadFeatureView() {
if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); } if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); }
if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); } if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); }
if (state.view === "performance") { state.performance = await api(`/api/performance?host=${encodeURIComponent($("#performance-host").value)}&hours=${encodeURIComponent($("#performance-range").value || "6")}`); renderPerformance(); } if (state.view === "performance") { state.performance = await api(`/api/performance?host=${encodeURIComponent($("#performance-host").value)}&hours=${encodeURIComponent($("#performance-range").value || "6")}`); renderPerformance(); }
if (state.view === "administration") { [state.users, state.settings, state.backups] = await Promise.all([api("/api/users"), api("/api/settings"), api("/api/backups")]); renderUsers(); window.renderExtendedViews?.(); } if (state.view === "administration") { [state.users, state.settings, state.backups] = await Promise.all([api("/api/users"), api("/api/settings"), api("/api/backups")]); state.usersLoaded = true; renderUsers(); window.renderExtendedViews?.(); }
if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.(); if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.();
restoreAdminTab(); restoreAdminTab();
} }
@@ -504,8 +507,9 @@ function render() {
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); $("#account-view").classList.toggle("hidden", state.view !== "account"); $("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); $("#account-view").classList.toggle("hidden", state.view !== "account");
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); } if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation"); $("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users"; const activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs"); 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");
if (overview) { if (overview) {
$("#page-title").textContent = "Dashboard"; $("#page-title").textContent = "Dashboard";
$("#page-subtitle").textContent = "Health, activity, and system status at a glance."; $("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
@@ -515,7 +519,7 @@ function render() {
if (!management) { if (!management) {
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] }; const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1]; const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
$("#open-create").textContent = state.view === "administration" ? " Create user" : state.view === "streaming" ? " New streaming host" : state.view === "redirects" ? " New redirect host" : state.view === "access" ? " New Access List" : $("#open-create").textContent; $("#open-create").textContent = state.view === "administration" ? (adminGroupsActive ? " Create group" : adminApiActive ? " Create token" : " Create user") : state.view === "streaming" ? " New streaming host" : state.view === "redirects" ? " New redirect host" : state.view === "access" ? " New Access List" : $("#open-create").textContent;
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0); if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
if (state.view === "streaming") { const items = state.streams; const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; $(".port-note").classList.add("hidden"); } if (state.view === "streaming") { const items = state.streams; const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; $(".port-note").classList.add("hidden"); }
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host"; if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
@@ -544,20 +548,103 @@ function render() {
} }
// --- Data refresh helpers ------------------------------------------------------------------ // --- Data refresh helpers ------------------------------------------------------------------
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/streams"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "streams", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } } // Each entry is the state key a call populates and the fetch that populates it. refresh() (the
// full, unscoped fetch) and refreshCurrentView() (the page-scoped fetch, see below) both build
// their request list from this single map, so adding a new piece of shared state only ever means
// adding one line here.
const REFRESH_ENDPOINTS = {
sites: () => api("/api/sites"),
proxies: () => api("/api/proxies"),
redirects: () => api("/api/redirects"),
streams: () => api("/api/streams"),
accessLists: () => api("/api/access-lists"),
groups: () => canAdmin() ? api("/api/groups") : Promise.resolve([]),
dashboard: () => api("/api/dashboard"),
certificates: () => api("/api/certificates"),
};
// Which of the keys above each view actually renders. A view not listed here (certificates, logs,
// performance, administration, account, documentation) already loads its own data separately via
// loadFeatureView() and never called refresh() at all, so it isn't included. Overview intentionally
// lists everything: its attention list and the sidebar's per-section counts summarize the whole
// gateway, not one section of it, so a scoped fetch there would defeat the point of the page.
const VIEW_REFRESH_KEYS = {
overview: Object.keys(REFRESH_ENDPOINTS),
hosted: ["sites"],
proxies: ["proxies"],
streaming: ["streams"],
redirects: ["redirects"],
access: ["accessLists", "groups"],
};
async function refreshKeys(keys) {
const results = await Promise.allSettled(keys.map(key => REFRESH_ENDPOINTS[key]()));
results.forEach((result, index) => { if (result.status === "fulfilled") state[keys[index]] = result.value; });
}
function maybeRefreshPendingProxies() {
const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id);
if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); }
}
// The original, unscoped refresh -- fetches every shared list plus the dashboard and certificate
// summaries in one pass. Kept for cases that genuinely need everything at once: first page load
// (boot()) and the Overview page, whose attention list and counts summarize the entire gateway.
async function refresh() { await refreshKeys(Object.keys(REFRESH_ENDPOINTS)); state.loaded = true; render(); window.renderExtendedViews?.(); maybeRefreshPendingProxies(); }
// The page-scoped refresh: fetches only the state a given view actually renders, instead of
// unconditionally re-fetching sites, proxies, redirects, streams, access lists, groups, the full
// dashboard snapshot, and certificates every single time -- regardless of which one page the user
// is looking at. This was the original, most direct cause behind "refreshing one page refetches
// the whole site": every action (create, edit, toggle, delete) and every manual refresh called the
// same all-8-endpoints refresh() no matter which view triggered it. Sidebar badge counts for
// sections other than the current view are not re-fetched by this path and can go briefly stale
// until the next full refresh() (a fresh page load, or a visit to Overview) -- an intentional
// trade for not fetching data the current page doesn't display.
async function refreshCurrentView() {
const keys = VIEW_REFRESH_KEYS[state.view] || Object.keys(REFRESH_ENDPOINTS);
await refreshKeys(keys);
state.loaded = true; render(); window.renderExtendedViews?.();
if (keys.includes("proxies")) maybeRefreshPendingProxies();
}
// Polls just /api/proxies for upstream health that wasn't ready yet on the last refresh() --
// 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 = []) { async function refreshPendingProxies(ids = []) {
const pending = new Set(ids.map(String)); const pending = new Set(ids.map(String));
for (const delay of [1000, 2000, 3000]) { for (const delay of [1000, 2000, 3000]) {
if (!pending.size) return; if (!pending.size) return;
await new Promise(resolve => setTimeout(resolve, delay)); 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)); for (const proxy of state.proxies) if (pending.has(String(proxy.id)) && proxy.upstream) pending.delete(String(proxy.id));
} }
} }
async function refreshDashboard() { async function refreshDashboard() {
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…'; $("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); } try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
finally { button.disabled = false; button.classList.remove("spinning"); } finally { /* no-op: the Live Health panel's own refresh icon was removed in favor of the page-level refresh button */ }
}
// Populates the Dashboard's hero panel (CPU/memory/swap/disk/network/uptime) directly from
// /api/system/health, the same call and the same renderHeroPanel() the Administration > System
// 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 ------------------- // --- Boot: session check, initial routing, periodic health/update checks -------------------
@@ -569,13 +656,20 @@ async function boot() {
$("#login-copy").textContent = session.installationSetupPending ? "Sign in using the administrator credentials you configured during installation." : "Sign in to manage your sites."; $("#login-copy").textContent = session.installationSetupPending ? "Sign in using the administrator credentials you configured during installation." : "Sign in to manage your sites.";
if (!session.authenticated) return showLogin(); if (!session.authenticated) return showLogin();
if (session.setupRequired) { $("#login").classList.add("hidden"); $("#dashboard").classList.add("hidden"); $("#setup-form [name=username]").value = session.user.username; if (!$("#setup-dialog").open) $("#setup-dialog").showModal(); return; } if (session.setupRequired) { $("#login").classList.add("hidden"); $("#dashboard").classList.add("hidden"); $("#setup-form [name=username]").value = session.user.username; if (!$("#setup-dialog").open) $("#setup-dialog").showModal(); return; }
state.view = location.hash.slice(1) || "overview"; state.users = []; showDashboard(); state.user = session.user; $("#user-label").textContent = session.user?.displayName || session.username; document.querySelectorAll(".admin-only").forEach(element => element.classList.toggle("hidden", !canAdmin())); render(); state.config = await api("/api/config"); state.view = location.hash.slice(1) || "overview"; state.users = []; state.usersLoaded = false; showDashboard(); state.user = session.user; $("#user-label").textContent = session.user?.displayName || session.username; document.querySelectorAll(".admin-only").forEach(element => element.classList.toggle("hidden", !canAdmin())); render(); state.config = await api("/api/config");
$("#version-label").textContent = `v${state.config.version || "unknown"}`; $("#version-label").textContent = `v${state.config.version || "unknown"}`;
if (!state.loadedVersion) state.loadedVersion = state.config.version; if (!state.loadedVersion) state.loadedVersion = state.config.version;
$("#port-range").textContent = `${state.config.minPort}${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}${state.config.maxPort}`; $("#port-range").textContent = `${state.config.minPort}${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}${state.config.maxPort}`;
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView(); $("#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.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); 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() { async function checkForUpdate() {
@@ -620,6 +714,21 @@ $("#dashboard-view").addEventListener("click", event => { const target = event.t
// --- Logs & Performance filter controls ----------------------------------------------------- // --- Logs & Performance filter controls -----------------------------------------------------
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error")));
// Generic page-scoped refresh button, shown on every view except Logs (which already has its own
// "Refresh logs" button wired to loadFeatureView()). Uses refreshCurrentView() for the shared-list
// views (Overview, Hosted, Proxy Hosts, Streaming, Redirects, Access Lists) so it fetches only
// what that page renders, and falls back to loadFeatureView() for every other view (Certificates,
// Performance, Administration, Account, Documentation), which already load their own data scoped
// to themselves.
$("#refresh-view").addEventListener("click", async () => {
const button = $("#refresh-view"); button.disabled = true; button.classList.add("spinning");
try {
if (state.view in VIEW_REFRESH_KEYS || state.view === "overview") await refreshCurrentView();
else await loadFeatureView();
toast("Refreshed.");
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; button.classList.remove("spinning"); }
});
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); $("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
@@ -653,12 +762,15 @@ $("#performance-rows").addEventListener("click", event => {
const pathsButton = event.target.closest("[data-paths-host]"); const pathsButton = event.target.closest("[data-paths-host]");
if (pathsButton) { const paths = state.performanceTopPaths?.[pathsButton.dataset.pathsHost]; if (paths) showTopPaths(pathsButton.dataset.pathsHost, paths); } if (pathsButton) { const paths = state.performanceTopPaths?.[pathsButton.dataset.pathsHost]; if (paths) showTopPaths(pathsButton.dataset.pathsHost, paths); }
}); });
$("#performance-hide-unconfigured").addEventListener("change", event => { state.performanceHideUnconfigured = event.target.checked; renderPerformance(); });
$("#log-status").addEventListener("change", renderLogs); $("#log-status").addEventListener("change", renderLogs);
$("#event-severity").addEventListener("change", renderLogs); $("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs); $("#event-category").addEventListener("change", renderLogs);
// --- "Create" dialog: opens the right create form/dialog for the current view -------------- // --- "Create" dialog: opens the right create form/dialog for the current view --------------
function openCreate() { function openCreate() {
if (state.view === "administration" && state.adminTab === "groups") { openNewGroupEditor(); return; }
if (state.view === "administration" && state.adminTab === "api") { openCreateApiTokenDialog(); return; }
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); } if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); } if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); } if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
@@ -674,9 +786,8 @@ document.addEventListener("keydown", event => { if (event.key === "Escape") clos
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); })); document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
// --- Hosted Sites & Proxy Hosts: create form submit handlers -------------------------------- // --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message, "error"))); $("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refreshCurrentView(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
$("#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 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"; } });
$("#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"; } });
// --- Health-check field visibility polish for the create forms ------------------------------ // --- Health-check field visibility polish for the create forms ------------------------------
@@ -691,7 +802,7 @@ function openSettings(kind, id) {
form.elements.name.value = item.name || ""; form.elements.domain.value = item.domain || ""; form.elements.target.value = item.target || ""; form.elements.tls.value = item.tls || "automatic"; form.elements.hsts.checked = Boolean(item.hsts); if (form.elements.settingsAccessListId) form.elements.settingsAccessListId.value = item.accessListId || ""; form.elements.name.value = item.name || ""; form.elements.domain.value = item.domain || ""; form.elements.target.value = item.target || ""; form.elements.tls.value = item.tls || "automatic"; form.elements.hsts.checked = Boolean(item.hsts); if (form.elements.settingsAccessListId) form.elements.settingsAccessListId.value = item.accessListId || "";
if (kind === "proxy") { if (kind === "proxy") {
const scope = "#settings-advanced"; const scope = "#settings-advanced";
setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic"); setScoped(form, scope, "blockCommonExploits", Boolean(item.blockCommonExploits)); setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic");
form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n"); form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n");
setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"));
form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; setScoped(form, scope, "upstreamTlsInsecure", Boolean(item.upstreamTlsInsecure)); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || ""); form.elements.upstreamsText.value = (item.upstreams || []).join("\n"); setScoped(form, scope, "lbPolicy", item.lbPolicy || "random"); form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; setScoped(form, scope, "upstreamTlsInsecure", Boolean(item.upstreamTlsInsecure)); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || ""); form.elements.upstreamsText.value = (item.upstreams || []).join("\n"); setScoped(form, scope, "lbPolicy", item.lbPolicy || "random");
@@ -710,7 +821,7 @@ $("#site-grid").addEventListener("click", async event => {
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind; const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return; if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
closeMenus(); closeMenus();
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } } if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refreshCurrentView(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } }
if (action === "settings") openSettings(kind, card.dataset.id); if (action === "settings") openSettings(kind, card.dataset.id);
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); } if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); } if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
@@ -754,7 +865,7 @@ window.openCaddyConfig = openCaddyConfig;
// --- Delete confirmation dialog and replace-files handler ------------------------------------ // --- Delete confirmation dialog and replace-files handler ------------------------------------
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; }); $("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refreshCurrentView(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; }); $("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; });
@@ -774,12 +885,13 @@ $("#icon-search").addEventListener("input", event => {
} catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; } } catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; }
}, 280); }, 280);
}); });
async function refreshIconTargetView() { await refresh(); if (state.iconTarget?.kind === "tokens") await window.loadApiTokens?.(); }
async function saveIcon(slug) { async function saveIcon(slug) {
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites";
$("#icon-error").textContent = ""; $("#icon-error").textContent = "";
try { try {
await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) }); await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
$("#icon-dialog").close(); await refresh(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored."); $("#icon-dialog").close(); await refreshIconTargetView(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored.");
} catch (error) { $("#icon-error").textContent = error.message; } } catch (error) { $("#icon-error").textContent = error.message; }
} }
$("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); }); $("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); });
@@ -787,13 +899,13 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa
$("#icon-upload").addEventListener("change", async event => { $("#icon-upload").addEventListener("change", async event => {
const file = event.target.files[0]; if (!file || !state.iconTarget) return; const file = event.target.files[0]; if (!file || !state.iconTarget) return;
const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = ""; const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = "";
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); } try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refreshIconTargetView(); toast("Custom icon saved locally."); }
catch (error) { $("#icon-error").textContent = error.message; } catch (error) { $("#icon-error").textContent = error.message; }
}); });
$("#save-icon-url").addEventListener("click", async () => { $("#save-icon-url").addEventListener("click", async () => {
const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; } const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; }
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites";
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); } try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refreshIconTargetView(); toast("Icon URL saved."); }
catch (error) { $("#icon-error").textContent = error.message; } catch (error) { $("#icon-error").textContent = error.message; }
}); });
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+243 -51
View File
File diff suppressed because one or more lines are too long
+46 -31
View File
File diff suppressed because one or more lines are too long
+85 -35
View File
@@ -44,14 +44,15 @@ h2{letter-spacing:-.025em}
.wide{width:100%} .wide{width:100%}
/* Top summary bar & status dot indicator */ /* 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{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;gap:9px;color:var(--muted);font-size:.88rem} .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)} .summary strong{color:var(--text)}
.port-note{margin-left:auto} .port-note{margin-left:auto}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--status-dot-idle)} .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--status-dot-idle)}
.status-dot.running{background:var(--green);box-shadow:0 0 0 4px rgba(var(--green-rgb),.1)} .status-dot.running{background:var(--green);box-shadow:0 0 0 4px rgba(var(--green-rgb),.1)}
.status-dot.disabled{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)} .status-dot.disabled{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)}
.status-dot.error,.status-dot.idle{background:var(--warning);box-shadow:0 0 0 4px rgba(var(--warning-rgb),.09)} .status-dot.error,.status-dot.idle{background:var(--warning);box-shadow:0 0 0 4px rgba(var(--warning-rgb),.09)}
.status-dot.bad{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)}
/* Hosted Site / Proxy Host cards, card menu, and the toggle switch */ /* Hosted Site / Proxy Host cards, card menu, and the toggle switch */
.site-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:18px} .site-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:18px}
@@ -64,6 +65,8 @@ h2{letter-spacing:-.025em}
.card-footer{position:absolute;left:20px;right:20px;bottom:20px} .card-footer{position:absolute;left:20px;right:20px;bottom:20px}
.status-pill{display:flex;align-items:center;gap:var(--space-2);text-transform:capitalize;font-size:var(--font-size-sm);color:var(--muted)} .status-pill{display:flex;align-items:center;gap:var(--space-2);text-transform:capitalize;font-size:var(--font-size-sm);color:var(--muted)}
.icon-button,.launch{width:34px;height:34px;border-radius:var(--radius-sm);border:1px solid var(--line);display:grid;place-items:center;background:var(--icon-button-bg);color:var(--muted);cursor:pointer;text-decoration:none} .icon-button,.launch{width:34px;height:34px;border-radius:var(--radius-sm);border:1px solid var(--line);display:grid;place-items:center;background:var(--icon-button-bg);color:var(--muted);cursor:pointer;text-decoration:none}
.page-refresh{width:44px;height:44px;flex:0 0 auto}
.header-actions{display:flex;align-items:center;gap:var(--space-5)}
.menu-wrap{position:relative} .menu-wrap{position:relative}
.menu{display:none;position:absolute;right:0;top:var(--space-7);width:145px;background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-2xs);padding:6px;box-shadow:var(--shadow);z-index:3} .menu{display:none;position:absolute;right:0;top:var(--space-7);width:145px;background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-2xs);padding:6px;box-shadow:var(--shadow);z-index:3}
.menu-open .menu{display:block} .menu-open .menu{display:block}
@@ -149,6 +152,7 @@ header{align-items:flex-end}
/* Dashboard */ /* Dashboard */
.mobile-nav{display:none} .mobile-nav{display:none}
.dashboard-view{margin-top:38px} .dashboard-view{margin-top:38px}
#certificates-view,#performance-view,#logs-view,#account-view,#documentation-view{margin-top:var(--space-7)}
.metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-4)} .metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-4)}
.metric-card{min-width:0;padding:20px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:linear-gradient(145deg,rgba(var(--panel2-rgb),.95),rgba(var(--card-shade-rgb),.95));color:var(--text);text-align:left;position:relative;overflow:hidden} .metric-card{min-width:0;padding:20px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:linear-gradient(145deg,rgba(var(--panel2-rgb),.95),rgba(var(--card-shade-rgb),.95));color:var(--text);text-align:left;position:relative;overflow:hidden}
.metric-card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--card-accent,var(--green));opacity:.85} .metric-card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--card-accent,var(--green));opacity:.85}
@@ -218,7 +222,9 @@ header{align-items:flex-end}
.live-dot.checking{background:var(--warning);animation-duration:.9s} .live-dot.checking{background:var(--warning);animation-duration:.9s}
@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(var(--green-rgb),.5)}70%{box-shadow:0 0 0 6px rgba(var(--green-rgb),0)}100%{box-shadow:0 0 0 0 rgba(var(--green-rgb),0)}} @keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(var(--green-rgb),.5)}70%{box-shadow:0 0 0 6px rgba(var(--green-rgb),0)}100%{box-shadow:0 0 0 0 rgba(var(--green-rgb),0)}}
.system-panel{position:relative;overflow:hidden} .system-panel{position:relative;overflow:hidden}
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--green);opacity:.85} .system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--green);opacity:.85;transition:background .2s}
.system-panel.tone-warning::before{background:var(--warning)}
.system-panel.tone-critical::before{background:var(--danger)}
.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0} .system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}
.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)} .system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
.system-grid dt{color:var(--muted);font-size:.72rem} .system-grid dt{color:var(--muted);font-size:.72rem}
@@ -249,6 +255,18 @@ header{align-items:flex-end}
@media(max-width:760px){.mobile-nav{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:0 0 30px;padding:var(--space-1);border:1px solid var(--line);border-radius:var(--radius-md);background:var(--panel)}.mobile-nav button{justify-content:center;text-align:center;padding:9px 5px;font-size:.72rem}.dashboard-view{margin-top:30px}.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric-card{padding:var(--space-4)}.metric-card strong{font-size:1.65rem}.dashboard-columns{grid-template-columns:1fr}.system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-strip{grid-template-columns:1fr}.metric-chip{padding:11px 14px}} @media(max-width:760px){.mobile-nav{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:0 0 30px;padding:var(--space-1);border:1px solid var(--line);border-radius:var(--radius-md);background:var(--panel)}.mobile-nav button{justify-content:center;text-align:center;padding:9px 5px;font-size:.72rem}.dashboard-view{margin-top:30px}.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric-card{padding:var(--space-4)}.metric-card strong{font-size:1.65rem}.dashboard-columns{grid-template-columns:1fr}.system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-strip{grid-template-columns:1fr}.metric-chip{padding:11px 14px}}
.upstream-copy{margin:10px 0 0;color:var(--green);font-size:.72rem} .upstream-copy{margin:10px 0 0;color:var(--green);font-size:.72rem}
.upstream-copy.bad{color:var(--danger)} .upstream-copy.bad{color:var(--danger)}
.upstream-copy.idle{color:var(--warning)}
.event-table-wrap{max-height:min(52vh,620px);overflow:auto;margin-top:var(--space-4)}
.event-table-wrap .event-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}
table.event-table th:nth-child(1),table.event-table td:nth-child(1){width:190px;white-space:nowrap;text-align:left}
table.event-table th:nth-child(2),table.event-table td:nth-child(2){width:110px;white-space:nowrap;text-align:left}
table.event-table th:nth-child(3),table.event-table td:nth-child(3){width:140px;white-space:nowrap;text-align:left}
table.event-table th:nth-child(4),table.event-table td:nth-child(4){width:auto;white-space:normal;overflow-wrap:anywhere;text-align:left}
@media(max-width:900px){table.event-table th:nth-child(1),table.event-table td:nth-child(1){width:150px}}
.event-table td .status-dot{margin-right:6px;vertical-align:-1px}
table.backup-history-table th:nth-child(1),table.backup-history-table td:nth-child(1){width:26%}
table.backup-history-table th:nth-child(2),table.backup-history-table td:nth-child(2),table.backup-history-table th:nth-child(3),table.backup-history-table td:nth-child(3){width:18%}
table.backup-history-table th:nth-child(4),table.backup-history-table td:nth-child(4){width:auto}
.activity-mark.bad{background:rgba(var(--danger-rgb),.12);color:var(--danger)} .activity-mark.bad{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
.feature-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:18px} .feature-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:18px}
.feature-summary>div{padding:18px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)} .feature-summary>div{padding:18px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
@@ -264,6 +282,9 @@ header{align-items:flex-end}
.log-toolbar{display:flex;align-items:end;justify-content:space-between;gap:var(--space-4)} .log-toolbar{display:flex;align-items:end;justify-content:space-between;gap:var(--space-4)}
.log-toolbar label{margin:0;min-width:250px} .log-toolbar label{margin:0;min-width:250px}
.feature-note{margin:var(--space-4) 0} .feature-note{margin:var(--space-4) 0}
.feature-note-row{display:flex;align-items:center;gap:var(--space-4)}
.feature-note-row .feature-note{flex:1;min-width:0}
.performance-hide-unconfigured{margin:0 0 0 auto;padding:0;border:0;background:transparent;font-size:.85rem;flex-shrink:0;white-space:nowrap}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)} .table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
.log-table{width:100%;border-collapse:collapse;font-size:var(--font-size-md)} .log-table{width:100%;border-collapse:collapse;font-size:var(--font-size-md)}
.log-table th,.log-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);white-space:nowrap} .log-table th,.log-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);white-space:nowrap}
@@ -335,7 +356,7 @@ header{align-items:flex-end}
.aside-footer{margin-top:0} .aside-footer{margin-top:0}
/* Administration tab bar */ /* Administration tab bar */
.admin-tabs{display:flex;gap:7px;margin-bottom:22px;padding:5px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--panel);overflow:auto} .admin-tabs{display:flex;gap:7px;margin:var(--space-7) 0 22px;padding:5px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--panel);overflow:auto}
.admin-tabs button{width:auto;white-space:nowrap;padding:9px var(--space-3);border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer} .admin-tabs button{width:auto;white-space:nowrap;padding:9px var(--space-3);border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.admin-tabs .tab-active{background:var(--panel2);color:var(--text)} .admin-tabs .tab-active{background:var(--panel2);color:var(--text)}
@@ -353,7 +374,7 @@ header{align-items:flex-end}
/* Generic settings-form layout */ /* Generic settings-form layout */
.settings-form{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin-top:22px;padding:22px;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--panel)} .settings-form{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin-top:22px;padding:22px;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--panel)}
.settings-form>label:has(textarea),.settings-form>.dialog-actions,.settings-form>.error{grid-column:1/-1} .settings-form>label:has(textarea),.settings-form>.dialog-actions,.settings-form>.error,.settings-form>.dialog-heading{grid-column:1/-1}
.settings-form.compact-grid{grid-template-columns:repeat(4,1fr)} .settings-form.compact-grid{grid-template-columns:repeat(4,1fr)}
.settings-form.compact-grid .check-control,.settings-form.compact-grid .dialog-actions{grid-column:auto} .settings-form.compact-grid .check-control,.settings-form.compact-grid .dialog-actions{grid-column:auto}
textarea{display:block;width:100%;min-height:110px;margin-top:7px;padding:var(--space-3);border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--field-bg);color:var(--text);font:inherit;resize:vertical;outline:none} textarea{display:block;width:100%;min-height:110px;margin-top:7px;padding:var(--space-3);border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--field-bg);color:var(--text);font:inherit;resize:vertical;outline:none}
@@ -371,8 +392,9 @@ textarea:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(var(--green-r
.redirect-card .card-footer{gap:var(--space-3)} .redirect-card .card-footer{gap:var(--space-3)}
.redirect-card .button{padding:7px 9px;font-size:var(--font-size-xs)} .redirect-card .button{padding:7px 9px;font-size:var(--font-size-xs)}
.chip{padding:5px var(--space-2);border-radius:var(--radius-full);background:var(--panel2);color:var(--muted);font-size:var(--font-size-xs)} .chip{padding:5px var(--space-2);border-radius:var(--radius-full);background:var(--panel2);color:var(--muted);font-size:var(--font-size-xs)}
.chip+small{margin-left:var(--space-2)}
.chip.type-complete{background:rgba(var(--green-rgb),.15);color:var(--green)} .chip.type-complete{background:rgba(var(--green-rgb),.15);color:var(--green)}
.chip.type-configuration{background:var(--panel2);color:var(--muted)} .chip.type-configuration{background:color-mix(in srgb,var(--accent-purple) 15%,transparent);color:var(--accent-purple)}
.chip.type-encrypted{background:rgba(var(--chip-blue-rgb),.15);color:var(--blue)} .chip.type-encrypted{background:rgba(var(--chip-blue-rgb),.15);color:var(--blue)}
.chip.type-unknown{background:rgba(var(--danger-rgb),.12);color:var(--danger)} .chip.type-unknown{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
.backup-summary{display:block;margin-top:6px} .backup-summary{display:block;margin-top:6px}
@@ -467,25 +489,21 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
#app .upstream-diagnostics summary{padding:0;color:var(--blue);font-size:var(--font-size-xs);font-weight:700;border:0} #app .upstream-diagnostics summary{padding:0;color:var(--blue);font-size:var(--font-size-xs);font-weight:700;border:0}
#app .upstream-diagnostics .upstream-detail{margin:6px 0 0} #app .upstream-diagnostics .upstream-detail{margin:6px 0 0}
/* Log table layout & column widths */ /* Log table layout & column widths -- matches the .event-table/.performance-table
pattern used by Gateway Events, Backup history, and the Audit log: sticky header
only (no pinned/divided trailing columns), and the Request column absorbs the
remaining width instead of leaving a gap on wide panels. */
.log-table-wrap{max-height:min(52vh,620px);overflow:auto} .log-table-wrap{max-height:min(52vh,620px);overflow:auto}
.log-table-wrap .log-table thead th{position:sticky;top:0;background:var(--panel);z-index:1} .log-table-wrap .log-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}
.log-section-heading{margin:20px 0 10px} .log-section-heading{margin:20px 0 10px}
.log-section-heading h2{margin:0;font-size:1.05rem} .log-section-heading h2{margin:0;font-size:1.05rem}
.log-section-heading p{margin:var(--space-1) 0 0;font-size:.76rem} .log-section-heading p{margin:var(--space-1) 0 0;font-size:.76rem}
@media(min-width:761px){#dashboard>aside{position:sticky;top:0;height:100vh;max-height:100vh;overflow-y:auto;align-self:start}} @media(min-width:761px){#dashboard>aside{position:sticky;top:0;height:100vh;max-height:100vh;overflow-y:auto;align-self:start}}
.log-table th:nth-last-child(2),.log-table td:nth-last-child(2),.log-table th:last-child,.log-table td:last-child{position:sticky;background:var(--panel);z-index:2}
.log-table th:nth-last-child(2),.log-table td:nth-last-child(2){right:104px;min-width:104px}
.log-table th:last-child,.log-table td:last-child{right:0;min-width:104px}
.log-table th:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}
.log-table td:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}
.log-table-wrap{overflow-x:hidden;overflow-y:auto}
.log-table{table-layout:fixed} .log-table{table-layout:fixed}
.log-table th:nth-child(1),.log-table td:nth-child(1){width:190px} .log-table th:nth-child(1),.log-table td:nth-child(1){width:190px}
.log-table th:nth-child(2),.log-table td:nth-child(2){width:220px} .log-table th:nth-child(2),.log-table td:nth-child(2){width:220px}
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){width:104px} .log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){width:104px}
.log-table th:nth-child(3),.log-table td:nth-child(3){white-space:normal;overflow-wrap:anywhere} .log-table th:nth-child(3),.log-table td:nth-child(3){width:auto;white-space:normal;overflow-wrap:anywhere}
.log-table th:last-child,.log-table td:last-child{box-shadow:-1px 0 0 var(--line)}
@media(max-width:900px){.log-table th:nth-child(1),.log-table td:nth-child(1){width:150px}.log-table th:nth-child(2),.log-table td:nth-child(2){width:170px}} @media(max-width:900px){.log-table th:nth-child(1),.log-table td:nth-child(1){width:150px}.log-table th:nth-child(2),.log-table td:nth-child(2){width:170px}}
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){text-align:center} .log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){text-align:center}
#access-dialog .dialog-heading .close-dialog{display:none} #access-dialog .dialog-heading .close-dialog{display:none}
@@ -598,6 +616,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
/* Backup & restore form layout */ /* Backup & restore form layout */
.settings-form .form-section{grid-column:1/-1;padding:var(--space-1) 0 20px;border-bottom:1px solid var(--line)} .settings-form .form-section{grid-column:1/-1;padding:var(--space-1) 0 20px;border-bottom:1px solid var(--line)}
#default-site-help{grid-column:1/-1;margin:0 0 var(--space-4)}
.settings-form .form-section+.form-section{padding-top:20px} .settings-form .form-section+.form-section{padding-top:20px}
.settings-form .form-section:last-of-type{border-bottom:0} .settings-form .form-section:last-of-type{border-bottom:0}
.settings-form .form-section .eyebrow{margin-bottom:var(--space-3)} .settings-form .form-section .eyebrow{margin-bottom:var(--space-3)}
@@ -671,7 +690,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
/* Documentation manual: intro & search */ /* Documentation manual: intro & search */
.docs-intro{margin-bottom:22px;padding:var(--space-5);border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--bg);color:var(--text)} .docs-intro{margin-bottom:22px;padding:var(--space-5);border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--bg);color:var(--text)}
.docs-intro h2{margin:0 0 10px} .docs-intro h2{margin:0 0 10px}
.docs-intro p:last-child{margin:0;color:var(--muted);max-width:850px;line-height:1.6} .docs-intro .docs-lede{margin:0;color:var(--muted);line-height:1.6}
.docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start} .docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}
.docs-nav{position:sticky;top:18px;height:calc(100vh - 170px);overflow:auto;display:grid;align-content:start;gap:6px;padding:14px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)} .docs-nav{position:sticky;top:18px;height:calc(100vh - 170px);overflow:auto;display:grid;align-content:start;gap:6px;padding:14px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
.docs-nav .eyebrow{margin:var(--space-1) 8px 8px} .docs-nav .eyebrow{margin:var(--space-1) 8px 8px}
@@ -773,9 +792,10 @@ label.check-control:has(input[name="upstreamTlsInsecure"]){position:relative;hei
label.check-control:has(input[name="upstreamTlsInsecure"]) span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} label.check-control:has(input[name="upstreamTlsInsecure"]) span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
label.check-control:has(input[name="upstreamTlsInsecure"]) small{position:absolute;left:0;top:calc(100% + 7px);width:100%;padding:0!important;white-space:normal} label.check-control:has(input[name="upstreamTlsInsecure"]) small{position:absolute;left:0;top:calc(100% + 7px);width:100%;padding:0!important;white-space:normal}
.docker-integration-section{margin-top:14px;padding-top:14px;border-top:1px solid var(--line)} .system-integrations:not(:empty){margin-top:var(--space-3)}
.docker-integration-heading{font-weight:600;margin:0 0 var(--space-2)} .system-integrations .check-control{margin:0}
.docker-integration-section .check-control{margin-top:var(--space-3)} #system-sync-status+p{margin-top:var(--space-3)}
#system-restart-status{margin-top:var(--space-3)}
/* Custom-drawn select (Task #20): native <select> popups can't be reliably themed dark across /* Custom-drawn select (Task #20): native <select> popups can't be reliably themed dark across
browsers, so this replaces the popup only -- the native select stays for value/form/event browsers, so this replaces the popup only -- the native select stays for value/form/event
@@ -804,10 +824,6 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.event-technical{margin-top:6px} .event-technical{margin-top:6px}
.event-technical summary{padding:0;color:var(--blue);font-size:.7rem;cursor:pointer} .event-technical summary{padding:0;color:var(--blue);font-size:.7rem;cursor:pointer}
.event-technical code{display:block;margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font-size:var(--font-size-xs)} .event-technical code{display:block;margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font-size:var(--font-size-xs)}
#audit-list{max-height:min(52vh,620px);overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel);scrollbar-gutter:stable}
#audit-list .event-row{align-items:center;padding:18px 20px}
#audit-list .activity-mark{width:8px;height:8px;min-width:8px;border-radius:50%;padding:0;font-size:0;background:var(--green);box-shadow:0 0 0 4px rgba(var(--green-rgb),.1)}
#audit-list .activity-mark.bad{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)}
/* Diagnostic section headings & readiness panels */ /* Diagnostic section headings & readiness panels */
.diagnostic-section-heading{margin:20px 0 10px;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)} .diagnostic-section-heading{margin:20px 0 10px;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)}
@@ -816,11 +832,22 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.diagnostic-section-heading p:last-child{margin:var(--space-1) 0 0} .diagnostic-section-heading p:last-child{margin:var(--space-1) 0 0}
.diagnostic-section-heading{margin-bottom:0} .diagnostic-section-heading{margin-bottom:0}
.diagnostic-section-heading + .log-table-wrap{border-top:0;border-radius:0 0 15px 15px} .diagnostic-section-heading + .log-table-wrap{border-top:0;border-radius:0 0 15px 15px}
.section-heading-row{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-4)}
.section-heading-row .muted{margin:0}
.diagnostic-section-heading + .cert-table-wrap{border-top:0;border-radius:0 0 15px 15px}
.cert-table-row{cursor:pointer}
.cert-table-row:hover{background:rgba(var(--green-rgb),.06)}
.cert-table-row:focus{outline:none}
.cert-table-row:focus-visible{outline:2px solid var(--green);outline-offset:-2px}
.cert-table td{vertical-align:middle}
.cert-table td .status-dot{margin-right:6px;vertical-align:-1px}
#cert-detail-body{padding:0;background:transparent;border-top:0;margin-top:var(--space-4)}
#certificate-list.diagnostic-list,#readiness-list.diagnostic-list,#gateway-log-list.diagnostic-list,#audit-list.diagnostic-list{border-radius:0 0 15px 15px} #certificate-list.diagnostic-list,#readiness-list.diagnostic-list,#gateway-log-list.diagnostic-list,#audit-list.diagnostic-list{border-radius:0 0 15px 15px}
.data-list>.quiet-state,.diagnostic-list>.quiet-state{padding:22px} .data-list>.quiet-state,.diagnostic-list>.quiet-state{padding:22px}
.readiness-panel,.log-activity{background:transparent;border:0;padding:0} .readiness-panel,.log-activity{background:transparent;border:0;padding:0}
.readiness-panel .panel-heading,.log-activity .panel-heading{margin:20px 0 0;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)} .readiness-panel .panel-heading,.log-activity .panel-heading{margin:20px 0 0;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)}
.log-activity .event-filters{margin:0;padding:0 20px 15px;border-left:1px solid var(--line);border-right:1px solid var(--line);background:var(--panel)} .log-activity .event-filters{margin:0;padding:0 20px 15px;border-left:1px solid var(--line);border-right:1px solid var(--line);background:var(--panel)}
.log-activity .event-table-wrap{border-top:0;border-radius:0 0 15px 15px;margin-top:0}
.log-activity .diagnostic-list{border:1px solid var(--line);border-radius:0 0 15px 15px;background:var(--panel);overflow:auto} .log-activity .diagnostic-list{border:1px solid var(--line);border-radius:0 0 15px 15px;background:var(--panel);overflow:auto}
.log-activity .event-row{gap:var(--space-4);padding:18px 20px;align-items:center} .log-activity .event-row{gap:var(--space-4);padding:18px 20px;align-items:center}
.log-activity .event-row .status-dot{flex:0 0 8px} .log-activity .event-row .status-dot{flex:0 0 8px}
@@ -895,7 +922,9 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.performance-table{width:100%;border-collapse:collapse;font-size:var(--font-size-md);table-layout:fixed} .performance-table{width:100%;border-collapse:collapse;font-size:var(--font-size-md);table-layout:fixed}
.performance-table th,.performance-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .performance-table th,.performance-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.performance-table thead th{border-top:0;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;white-space:normal} .performance-table thead th{border-top:0;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;white-space:normal}
.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:22%} .performance-table-wrap .performance-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}
.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:22%;white-space:normal;overflow-wrap:anywhere}
.unconfigured-chip{margin-left:8px;white-space:nowrap;display:inline-block}
.performance-table th:nth-child(n+2),.performance-table td:nth-child(n+2){width:11.1%;text-align:center} .performance-table th:nth-child(n+2),.performance-table td:nth-child(n+2){width:11.1%;text-align:center}
.performance-table .count-divider{color:var(--muted);margin:0 2px} .performance-table .count-divider{color:var(--muted);margin:0 2px}
.performance-table .count-link-button{border:0;background:none;padding:0;font:inherit;color:var(--danger);cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px} .performance-table .count-link-button{border:0;background:none;padding:0;font:inherit;color:var(--danger);cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px}
@@ -945,8 +974,9 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.copy-icon{width:16px;height:16px;display:block} .copy-icon{width:16px;height:16px;display:block}
/* Docker container picker */ /* Docker container picker */
.target-with-picker{display:flex;gap:var(--space-2);align-items:center} .target-with-picker{display:flex;gap:var(--space-2);align-items:center;margin-top:7px}
.target-with-picker input{flex:1;min-width:0} .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-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{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)} .container-choice:hover{border-color:var(--border-hover)}
@@ -955,17 +985,37 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.container-choice.unreachable:hover{border-color:var(--line)} .container-choice.unreachable:hover{border-color:var(--line)}
/* API access tokens */ /* API access tokens */
.api-token-row{grid-template-columns:auto minmax(150px,1.3fr) minmax(110px,.9fr) minmax(130px,1fr) minmax(150px,1fr)} .api-token-card{min-height:0}
.api-token-row.revoked{opacity:.6} .api-token-card.revoked{opacity:.6}
.api-token-card .address{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.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} .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}
.api-token-chip{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm)}
/* Backup history timeline */
.backup-history-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}
.backup-history-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
.backup-history-copy strong{font-size:var(--font-size-md)}
.backup-history-copy small{color:var(--muted);font-size:var(--font-size-xs)}
.backup-history-copy small.failed{color:var(--danger)}
.check-control.is-disabled{opacity:.55} .check-control.is-disabled{opacity:.55}
.check-control.is-disabled span{color:var(--muted)} .check-control.is-disabled span{color:var(--muted)}
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)} .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-value.warning{color:var(--warning)}
.system-hero-value.critical{color:var(--danger)}
.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))}}
+355 -61
View File
@@ -5,6 +5,7 @@ import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
import http from "node:http"; import http from "node:http";
import net from "node:net"; import net from "node:net";
import os from "node:os";
import dgram from "node:dgram"; import dgram from "node:dgram";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -63,6 +64,14 @@ let caddyVersion = "Unknown";
const recentActivity = []; const recentActivity = [];
const upstreamHealth = new Map(); const upstreamHealth = new Map();
const certificateStatusCache = 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 loginAttempts = new Map();
const rateLimitBuckets = new Map(); const rateLimitBuckets = new Map();
let dockerSocketMounted = false; let dockerSocketMounted = false;
@@ -82,15 +91,174 @@ function recordActivity(message, status = "ok") {
try { storage?.recordAudit(message, status, null, currentAuditActor); } catch (error) { console.warn("Could not record SQLite audit event:", error.message); } try { storage?.recordAudit(message, status, null, currentAuditActor); } catch (error) { console.warn("Could not record SQLite audit event:", error.message); }
} }
// --- Edit-route change summaries: builds one human-readable, comma-joined line describing
// exactly what changed in a single PATCH save, instead of a generic "X updated" message
// that looks identical whether a rename, a domain change, or a TLS toggle happened.
function describeDomainsChange(previousDomains, nextDomains) {
if (previousDomains.join("|") === nextDomains.join("|")) return null;
if (!nextDomains.length) return "domain removed";
const shown = nextDomains.slice(0, 3).join(", ");
return `domain${nextDomains.length > 1 ? "s" : ""} changed to ${shown}${nextDomains.length > 3 ? ` (+${nextDomains.length - 3} more)` : ""}`;
}
function summarizeUpdate(kind, name, changes) {
const list = changes.filter(Boolean);
if (!list.length) return;
recordActivity(`${kind}${name}” updated — ${list.join(", ")}.`);
}
async function directorySize(directory) { async function directorySize(directory) {
let total = 0;
const entries = 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));
for (const entry of entries) { const sizes = await Promise.all(entries.map(async entry => {
const itemPath = path.join(directory, entry.name); const itemPath = path.join(directory, entry.name);
if (entry.isDirectory()) total += await directorySize(itemPath); if (entry.isDirectory()) return directorySize(itemPath);
else if (entry.isFile()) total += (await fsp.stat(itemPath)).size; if (entry.isFile()) return (await fsp.stat(itemPath)).size;
} return 0;
return total; }));
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();
// A recursive walk of /data (directorySize()) is only needed when DATA_DIR_LIMIT_GB is set, and
// only to compute one denominator-relative percentage -- disk usage doesn't change fast enough to
// justify redoing that walk on every single hero-panel poll (every 7 seconds, times every
// concurrent viewer). Cached in the background instead, same pattern as refreshDatabaseIntegrityCache()
// above: compute once shortly after boot, then on a steady interval, and have the hot request path
// just read the cached number.
let dataDirSizeCache = { checkedAt: null, bytes: null };
async function refreshDataDirSizeCache() {
try { dataDirSizeCache = { checkedAt: new Date().toISOString(), bytes: await directorySize(dataDir) }; }
catch (error) { console.warn("Could not compute data directory size:", error.message); }
}
// 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] = await Promise.all([
cgroupCpuPercent(),
cgroupMemory(),
cgroupSwap(),
fsp.statfs(dataDir).catch(() => null),
]);
// See refreshDataDirSizeCache() above -- this used to be a live directorySize() walk on every fetch.
const appUsedBytes = assignedLimitBytes !== null ? dataDirSizeCache.bytes : 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) { function numberEnv(name, fallback) {
@@ -400,7 +568,6 @@ function applyAdvancedSettings(item, body) {
if (body.accessListId !== undefined) item.accessListId = String(body.accessListId || ""); if (body.accessListId !== undefined) item.accessListId = String(body.accessListId || "");
if (body.compression !== undefined) item.compression = ["off", "gzip", "automatic"].includes(body.compression) ? body.compression : "automatic"; if (body.compression !== undefined) item.compression = ["off", "gzip", "automatic"].includes(body.compression) ? body.compression : "automatic";
if (body.hstsSubdomains !== undefined) item.hstsSubdomains = Boolean(body.hstsSubdomains); if (body.hstsSubdomains !== undefined) item.hstsSubdomains = Boolean(body.hstsSubdomains);
if (body.blockCommonExploits !== undefined) item.blockCommonExploits = Boolean(body.blockCommonExploits);
if (body.requestHeaders !== undefined) item.requestHeaders = cleanHeaders(body.requestHeaders); if (body.requestHeaders !== undefined) item.requestHeaders = cleanHeaders(body.requestHeaders);
if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders); if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders);
if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253); if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253);
@@ -459,19 +626,8 @@ function accessDirectives(accessListId) {
return output; return output;
} }
// Static, general-purpose ruleset for the "Block common exploits" toggle — not a full WAF. Rejects
// requests whose path matches common exploit-probe patterns before they reach the upstream: directory
// traversal, WordPress/PHP admin and scanner paths, dotfile exposure attempts, and SQL-injection-style
// query strings. One named matcher + one respond directive per host, so it's cheap to add or remove.
const COMMON_EXPLOIT_PATTERN = String.raw`(?i)(\.\./|\.\.\\|/etc/passwd|/wp-login\.php|/wp-admin(?:/|$)|/xmlrpc\.php|/\.env(?:$|\?)|/\.git/|/\.aws/|/vendor/phpunit|/phpunit(?:/|$)|eval\(|base64_decode\(|union(?:\s|%20|\+)+select|<script)`;
function exploitBlockDirectives(id) {
return [` @blocked-exploit-${id} {`, ` path_regexp ${caddyQuote(COMMON_EXPLOIT_PATTERN)}`, " }", ` respond @blocked-exploit-${id} 403`];
}
function commonHostDirectives(item) { function commonHostDirectives(item) {
const output = [...accessDirectives(item.accessListId)]; const output = [...accessDirectives(item.accessListId)];
if (item.blockCommonExploits) output.push(...exploitBlockDirectives(item.id));
if (item.compression !== "off") output.push(item.compression === "gzip" ? " encode gzip" : " encode zstd gzip"); if (item.compression !== "off") output.push(item.compression === "gzip" ? " encode gzip" : " encode zstd gzip");
for (const header of item.responseHeaders || []) output.push(` header ${header.name} ${caddyQuote(header.value)}`); for (const header of item.responseHeaders || []) output.push(` header ${header.name} ${caddyQuote(header.value)}`);
if (item.hsts && item.tls !== "http") output.push(` header Strict-Transport-Security ${caddyQuote(`max-age=31536000${item.hstsSubdomains ? "; includeSubDomains" : ""}`)}`); if (item.hsts && item.tls !== "http") output.push(` header Strict-Transport-Security ${caddyQuote(`max-age=31536000${item.hstsSubdomains ? "; includeSubDomains" : ""}`)}`);
@@ -663,6 +819,27 @@ async function syncCaddy() {
let configDrift = { checkedAt: null, drift: false, detail: null }; 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; let lastKnownGoodCaddyConfig = null;
function caddyAdminRequest(options, body) { function caddyAdminRequest(options, body) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -727,13 +904,14 @@ function publicStream(stream) {
// --- Certificate inventory & domain readiness diagnostics -------------------------------------- // --- Certificate inventory & domain readiness diagnostics --------------------------------------
async function walkFiles(directory) { async function walkFiles(directory) {
const output = []; const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
for (const entry of 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); const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) output.push(...await walkFiles(fullPath)); if (entry.isDirectory()) return walkFiles(fullPath);
else if (entry.isFile()) output.push(fullPath); if (entry.isFile()) return [fullPath];
} return [];
return output; }));
return results.flat();
} }
function certificateNames(certificate) { function certificateNames(certificate) {
@@ -743,18 +921,19 @@ function certificateNames(certificate) {
} }
async function certificateInventory() { 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" }))] 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"); .filter(item => item.enabled && item.domain && item.tls !== "http");
const configuredDomains = configured.flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain }))); const configuredDomains = configured.flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain })));
const parsed = []; const [managedCertificateFiles, customCertificateFiles] = await Promise.all([walkFiles(certificateDir), walkFiles(customCertificatesDir)]);
const certificateFiles = [...await walkFiles(certificateDir), ...await walkFiles(customCertificatesDir)]; const certificateFiles = [...managedCertificateFiles, ...customCertificateFiles];
for (const filename of certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file))) { const parsed = (await Promise.all(certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file)).map(async filename => {
try { try {
const certificate = new crypto.X509Certificate(await fsp.readFile(filename)); const [contents, stat] = await Promise.all([fsp.readFile(filename), fsp.stat(filename)]);
const stat = await fsp.stat(filename); const certificate = new crypto.X509Certificate(contents);
parsed.push({ certificate, names: certificateNames(certificate), updatedAt: stat.mtime.toISOString(), filename, source: filename.startsWith(customCertificatesDir) ? "Custom upload" : "Caddy / ACME" }); return { 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. */ } } catch { return null; /* Ignore non-certificate PEM files and unreadable entries. */ }
} }))).filter(Boolean);
const certificates = configuredDomains.map(item => { 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))))); const found = parsed.find(entry => entry.names.some(name => name === item.domain || (name.startsWith("*.") && item.domain.endsWith(name.slice(1)))));
if (!found) { if (!found) {
@@ -769,7 +948,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); } 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; 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) { async function pruneOrphanedCertificates(candidateDomains) {
@@ -791,15 +972,14 @@ 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 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 [certs, httpResponding, httpsResponding] = await Promise.all([precomputedCertificates ? Promise.resolve(precomputedCertificates) : certificateInventory(), tcpProbe(80), tcpProbe(443)]);
const [httpResponding, httpsResponding] = await Promise.all([tcpProbe(80), tcpProbe(443)]);
return Promise.all(routes.map(async item => { return Promise.all(routes.map(async item => {
let addresses = [], dnsError = null; 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; } try { addresses = [...new Set((await dns.lookup(item.domain, { all: true })).map(value => value.address))]; } catch (error) { dnsError = error.code || error.message; }
const certificate = certs.certificates.find(cert => cert.domain === item.domain) || null; const certificate = certs.certificates.find(cert => cert.domain === item.domain) || null;
const upstream = item.kind === "Proxy host" ? upstreamHealth.get(item.id) || null : null; const upstream = (item.kind === "Proxy host" || item.kind === "Hosted site") ? upstreamHealth.get(item.id) || null : null;
return { id: item.id, domain: item.domain, name: item.name, kind: item.kind, dns: { healthy: addresses.length > 0, addresses, error: dnsError }, ports: { http: httpResponding, https: item.tls === "http" ? null : httpsResponding }, tls: item.tls === "http" ? { status: "not-configured" } : { status: certificate?.status || "pending" }, upstream }; return { id: item.id, domain: item.domain, name: item.name, kind: item.kind, dns: { healthy: addresses.length > 0, addresses, error: dnsError }, ports: { http: httpResponding, https: item.tls === "http" ? null : httpsResponding }, tls: item.tls === "http" ? { status: "not-configured" } : { status: certificate?.status || "pending" }, upstream };
})); }));
} }
@@ -952,12 +1132,12 @@ async function cacheIcon(slug) {
// --- Dashboard snapshot: aggregates health/status across every subsystem for the // --- Dashboard snapshot: aggregates health/status across every subsystem for the
// Overview page and the /api/dashboard endpoint -------------------------------------------- // Overview page and the /api/dashboard endpoint --------------------------------------------
async function dashboardSnapshot() { async function dashboardSnapshot(precomputedCertificates) {
const hosted = sites.map(publicSite); const hosted = sites.map(publicSite);
const proxyHosts = proxies.map(publicProxy); const proxyHosts = proxies.map(publicProxy);
const enabledStreams = streams.filter(item => item.enabled !== false); const enabledStreams = streams.filter(item => item.enabled !== false);
const streamingPorts = { total: enabledStreams.length, listening: enabledStreams.filter(item => activeStreams.has(item.id)).length }; 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 tlsDomains = [...sites, ...proxies].filter(item => item.enabled && item.domain && item.tls !== "http").length;
const [storageWritable, gatewayResponding, httpResponding, httpsResponding] = await Promise.all([ const [storageWritable, gatewayResponding, httpResponding, httpsResponding] = await Promise.all([
fsp.access(dataDir, fs.constants.R_OK | fs.constants.W_OK).then(() => true).catch(() => false), fsp.access(dataDir, fs.constants.R_OK | fs.constants.W_OK).then(() => true).catch(() => false),
@@ -981,7 +1161,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"}.` }); 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" }); 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 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 { return {
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
gateway: { ...gatewayProbe, lastReload: lastGatewayReload }, gateway: { ...gatewayProbe, lastReload: lastGatewayReload },
@@ -1008,12 +1188,21 @@ async function dashboardSnapshot() {
caddyVersion, caddyVersion,
nodeVersion: process.version, nodeVersion: process.version,
databaseEngine: "SQLite", 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, databaseBytes: (await fsp.stat(storage.databasePath).catch(() => null))?.size || 0,
publicIp: publicIpState.address, publicIp: publicIpState.address,
publicIpCheckedAt: publicIpState.checkedAt, publicIpCheckedAt: publicIpState.checkedAt,
publicIpError: publicIpState.error, publicIpError: publicIpState.error,
jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }, { name: "Public IP check", enabled: true, schedule: "60m" }, { name: "Configuration drift check", enabled: true, schedule: "10m" }] jobs: [
{ name: "Upstream checks", enabled: true, schedule: "60s", lastRunAt: lastUpstreamCheckAt },
{ name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off", lastRunAt: settings.backups?.lastRunAt || null, lastStatus: settings.backups?.lastStatus || null },
{ name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off", lastRunAt: settings.logsRetention?.lastRunAt || null },
{ name: "Access-log import", enabled: true, schedule: "30s", lastRunAt: lastAccessLogImportAt },
{ name: "Public IP check", enabled: true, schedule: "60m", lastRunAt: publicIpState.checkedAt || null },
{ name: "Configuration drift check", enabled: true, schedule: "10m", lastRunAt: configDrift.checkedAt || null },
{ name: "Database integrity check", enabled: true, schedule: "30m", lastRunAt: databaseIntegrityCache.checkedAt || null },
{ name: "Disk usage refresh", enabled: true, schedule: "60s", lastRunAt: dataDirSizeCache.checkedAt || null },
]
}, },
activity: recentActivity activity: recentActivity
}; };
@@ -1572,13 +1761,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 } })); 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 ----------------------------------- // --- 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) => { app.get("/api/system/storage", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try { try {
const breakdown = {}; const breakdownDirs = { sites: sitesDir, backups: backupsDir, certificates: certificatesRoot, logs: logsDir, database: path.join(dataDir, "database") };
for (const [key, dir] of Object.entries({ 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)])));
breakdown[key] = await directorySize(dir);
}
let capacity = null; let capacity = null;
try { try {
const stats = await fsp.statfs(dataDir); const stats = await fsp.statfs(dataDir);
@@ -1590,7 +1785,7 @@ app.get("/api/system/storage", async (req, res, next) => {
// Inspects this container's own restart policy via the Docker Engine API, reusing the same // Inspects this container's own restart policy via the Docker Engine API, reusing the same
// mounted-socket + self-identification (process.env.HOSTNAME) pattern as the container picker. // mounted-socket + self-identification (process.env.HOSTNAME) pattern as the container picker.
async function ownRestartPolicy() { async function ownRestartPolicy() {
if (!dockerSocketMounted) return { checked: false, policyName: null, restartAvailable: false, reason: "Docker socket not detected — mount /var/run/docker.sock to check the restart policy." }; if (!dockerSocketMounted) return { checked: false, policyName: null, restartAvailable: false, reason: "Restart availability cant be verified — mount /var/run/docker.sock into this container so Site Gateway can confirm it will come back up before offering a restart." };
const ownId = String(process.env.HOSTNAME || "").trim(); const ownId = String(process.env.HOSTNAME || "").trim();
if (!ownId) return { checked: false, policyName: null, restartAvailable: false, reason: "Could not determine this container's own ID." }; if (!ownId) return { checked: false, policyName: null, restartAvailable: false, reason: "Could not determine this container's own ID." };
try { try {
@@ -1645,7 +1840,7 @@ app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json
app.get("/api/tokens", (req, res, next) => { app.get("/api/tokens", (req, res, next) => {
try { try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) }))); res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, icon: token.icon, iconSlug: token.iconSlug, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) })));
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
app.post("/api/tokens", async (req, res, next) => { app.post("/api/tokens", async (req, res, next) => {
@@ -1774,7 +1969,12 @@ app.get("/api/certificates", async (req, res, next) => {
catch (error) { next(error); } catch (error) { next(error); }
}); });
app.post("/api/health/check", async (req, res, next) => { 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); } 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); } }); app.get("/api/readiness", async (req, res, next) => { try { res.json({ checkedAt: new Date().toISOString(), routes: await domainReadiness() }); } catch (error) { next(error); } });
@@ -1786,8 +1986,9 @@ app.get("/api/support-report", async (req, res, next) => {
try { try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const certificateReport = await certificateInventory(); const certificateReport = await certificateInventory();
const readiness = await domainReadiness(certificateReport);
certificateReport.latestError = certificateReport.latestError ? { present:true, at:certificateReport.latestError.at } : null; 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)); 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); } } catch (error) { next(error); }
}); });
@@ -1870,6 +2071,24 @@ function entryLabel(item) {
} }
app.put("/api/:kind/:id/icon", async (req, res, next) => { app.put("/api/:kind/:id/icon", async (req, res, next) => {
try { try {
if (req.params.kind === "tokens") {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
if (!existing) return res.status(404).json({ error: "API token not found." });
let updated;
if (req.body.url !== undefined) {
const url = String(req.body.url || "").trim();
if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." });
updated = storage.setApiTokenIcon(req.params.id, { icon: url, iconSlug: null });
recordActivity(`Icon URL updated for “${entryLabel(existing)}”.`);
} else {
const slug = String(req.body.slug || "").trim();
const icon = slug ? await cacheIcon(slug) : null;
updated = storage.setApiTokenIcon(req.params.id, { icon, iconSlug: slug || null });
recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(existing)}”.`);
}
return res.json({ ...updated, ownerUsername: users.find(item => item.id === updated.ownerUserId)?.username || "unknown", revoked: Boolean(updated.revokedAt) });
}
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null; const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
if (!collection) return res.status(404).json({ error: "Entry type not found." }); if (!collection) return res.status(404).json({ error: "Entry type not found." });
const item = collection.find(entry => entry.id === req.params.id); const item = collection.find(entry => entry.id === req.params.id);
@@ -1893,6 +2112,19 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
}); });
app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => { app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => {
try { try {
if (req.params.kind === "tokens") {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
if (!existing) return res.status(404).json({ error: "API token not found." });
if (!req.file) return res.status(400).json({ error: "Choose an icon image." });
if (!/^image\/(png|jpeg|webp|gif|svg\+xml)$/.test(req.file.mimetype)) return res.status(400).json({ error: "Use PNG, JPEG, WebP, GIF, or SVG." });
const extension = req.file.mimetype === "image/svg+xml" ? "svg" : req.file.mimetype.split("/")[1].replace("jpeg", "jpg");
const filename = `${req.params.kind}-${existing.id}.${extension}`;
await fsp.rename(req.file.path, path.join(iconsDir, filename));
const updated = storage.setApiTokenIcon(req.params.id, { icon: `/site-icons/${filename}`, iconSlug: null });
recordActivity(`Custom icon uploaded for “${entryLabel(existing)}”.`);
return res.json({ ...updated, ownerUsername: users.find(item => item.id === updated.ownerUserId)?.username || "unknown", revoked: Boolean(updated.revokedAt) });
}
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null; const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
if (!collection) return res.status(404).json({ error: "Entry type not found." }); if (!collection) return res.status(404).json({ error: "Entry type not found." });
const item = collection.find(entry => entry.id === req.params.id); const item = collection.find(entry => entry.id === req.params.id);
@@ -1960,7 +2192,7 @@ app.post("/api/sites/:id/files", upload.single("files"), async (req, res, next)
if (!req.file) return res.status(400).json({ error: "Choose a ZIP file or index.html." }); if (!req.file) return res.status(400).json({ error: "Choose a ZIP file or index.html." });
await installUpload(site, req.file); await installUpload(site, req.file);
await syncCaddy(); await syncCaddy();
recordActivity(`Files replaced for “${site.name}.`); recordActivity(`Hosted site “${site.name}” files replaced.`);
res.json(publicSite(site)); res.json(publicSite(site));
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
@@ -1983,6 +2215,7 @@ app.patch("/api/sites/:id", async (req, res, next) => {
const site = sites.find(item => item.id === req.params.id); const site = sites.find(item => item.id === req.params.id);
if (!site) return res.status(404).json({ error: "Site not found." }); if (!site) return res.status(404).json({ error: "Site not found." });
const previousDomains = normalizeDomains(site.domain, site.domains); const previousDomains = normalizeDomains(site.domain, site.domains);
const before = { name: site.name, tls: site.tls, hsts: site.hsts, accessListId: site.accessListId, healthEnabled: site.healthEnabled, healthDetail: JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]) };
const domain = normalizeDomain(req.body.domain); const domain = normalizeDomain(req.body.domain);
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains);
const domainError = validateDomains(domains, site.id); const domainError = validateDomains(domains, site.id);
@@ -2001,7 +2234,16 @@ app.patch("/api/sites/:id", async (req, res, next) => {
await syncCaddy(); await syncCaddy();
await pruneOrphanedCertificates(previousDomains); await pruneOrphanedCertificates(previousDomains);
await saveSites(); await saveSites();
recordActivity(`Gateway settings updated for “${site.name}”.`); const afterHealthDetail = JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]);
summarizeUpdate("Hosted site", site.name, [
before.name !== site.name ? `renamed from “${before.name}` : null,
describeDomainsChange(previousDomains, domains),
before.tls !== site.tls ? `TLS set to ${site.tls}` : null,
before.hsts !== site.hsts ? `HSTS ${site.hsts ? "enabled" : "disabled"}` : null,
before.accessListId !== site.accessListId ? (site.accessListId ? "Access List assigned" : "Access List removed") : null,
before.healthEnabled !== site.healthEnabled ? `health checks ${site.healthEnabled === false ? "disabled" : "enabled"}` : null,
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
]);
res.json(publicSite(site)); res.json(publicSite(site));
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
@@ -2040,6 +2282,7 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
const proxy = proxies.find(item => item.id === req.params.id); const proxy = proxies.find(item => item.id === req.params.id);
if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); if (!proxy) return res.status(404).json({ error: "Proxy host not found." });
const previousDomains = normalizeDomains(proxy.domain, proxy.domains); const previousDomains = normalizeDomains(proxy.domain, proxy.domains);
const before = { name: proxy.name, target: proxy.target, tls: proxy.tls, hsts: proxy.hsts, accessListId: proxy.accessListId, healthEnabled: proxy.healthEnabled, healthDetail: JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]) };
if (req.body.domain !== undefined) { if (req.body.domain !== undefined) {
const domain = normalizeDomain(req.body.domain); const domain = normalizeDomain(req.body.domain);
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains);
@@ -2064,7 +2307,17 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
await syncCaddy(); await syncCaddy();
await pruneOrphanedCertificates(previousDomains); await pruneOrphanedCertificates(previousDomains);
await saveProxies(); await saveProxies();
recordActivity(`Proxy host “${proxy.name}” updated.`); const afterHealthDetail = JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]);
summarizeUpdate("Proxy host", proxy.name, [
before.name !== proxy.name ? `renamed from “${before.name}` : null,
describeDomainsChange(previousDomains, normalizeDomains(proxy.domain, proxy.domains)),
before.target !== proxy.target ? `target changed to ${proxy.target}` : null,
before.tls !== proxy.tls ? `TLS set to ${proxy.tls}` : null,
before.hsts !== proxy.hsts ? `HSTS ${proxy.hsts ? "enabled" : "disabled"}` : null,
before.accessListId !== proxy.accessListId ? (proxy.accessListId ? "Access List assigned" : "Access List removed") : null,
before.healthEnabled !== proxy.healthEnabled ? `health checks ${proxy.healthEnabled === false ? "disabled" : "enabled"}` : null,
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
]);
res.json(publicProxy(proxy)); res.json(publicProxy(proxy));
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
@@ -2193,6 +2446,7 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
try { try {
const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." }); const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." });
const previousDomains = normalizeDomains(item.domain, item.domains); const previousDomains = normalizeDomains(item.domain, item.domains);
const before = { name: item.name, target: item.target, accessListId: item.accessListId, enabled: item.enabled, code: item.code, preservePath: item.preservePath, tls: item.tls, hsts: item.hsts };
if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; } if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; }
if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled); if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled);
for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim(); for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim();
@@ -2201,7 +2455,19 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath); if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath);
if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls; if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls;
if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts); if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts);
await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” updated.`); res.json(item); await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects();
summarizeUpdate("Redirect Host", item.name, [
before.name !== item.name ? `renamed from “${before.name}` : null,
describeDomainsChange(previousDomains, normalizeDomains(item.domain, item.domains)),
before.target !== item.target ? `destination changed to ${item.target}` : null,
before.code !== item.code ? `redirect code changed to ${item.code}` : null,
before.preservePath !== item.preservePath ? `path preservation ${item.preservePath ? "enabled" : "disabled"}` : null,
before.tls !== item.tls ? `TLS set to ${item.tls}` : null,
before.hsts !== item.hsts ? `HSTS ${item.hsts ? "enabled" : "disabled"}` : null,
before.accessListId !== item.accessListId ? (item.accessListId ? "Access List assigned" : "Access List removed") : null,
before.enabled !== item.enabled ? (item.enabled ? "enabled" : "disabled") : null
]);
res.json(item);
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
app.delete("/api/redirects/:id", async (req, res, next) => { app.delete("/api/redirects/:id", async (req, res, next) => {
@@ -2233,6 +2499,7 @@ app.post("/api/streams", async (req, res, next) => {
app.patch("/api/streams/:id", async (req, res, next) => { app.patch("/api/streams/:id", async (req, res, next) => {
try { try {
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." }); const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
const before = { name: stream.name, port: stream.port, target: stream.target, tcp: stream.tcp, udp: stream.udp, healthEnabled: stream.healthEnabled, enabled: stream.enabled };
const next_ = { ...stream }; const next_ = { ...stream };
if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; } if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; }
if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; } if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; }
@@ -2248,7 +2515,15 @@ app.patch("/api/streams/:id", async (req, res, next) => {
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] }); if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); } else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
await saveStreams(); await saveStreams();
recordActivity(`Streaming host “${stream.name}” updated.`); const protocolLabel = (tcp, udp) => tcp && udp ? "TCP+UDP" : tcp ? "TCP" : "UDP";
summarizeUpdate("Streaming host", stream.name, [
before.name !== stream.name ? `renamed from “${before.name}` : null,
before.port !== stream.port ? `port changed from ${before.port} to ${stream.port}` : null,
before.target !== stream.target ? `target changed to ${stream.target}` : null,
(before.tcp !== stream.tcp || before.udp !== stream.udp) ? `protocol changed to ${protocolLabel(stream.tcp, stream.udp)}` : null,
before.healthEnabled !== stream.healthEnabled ? `health checks ${stream.healthEnabled === false ? "disabled" : "enabled"}` : null,
before.enabled !== stream.enabled ? (stream.enabled ? "enabled" : "disabled") : null
]);
res.json(publicStream(stream)); res.json(publicStream(stream));
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
@@ -2299,7 +2574,19 @@ app.patch("/api/settings", async (req, res, next) => {
recordActivity("Administration settings updated."); res.json({ ...settings, backupDirectory: backupsDir }); recordActivity("Administration settings updated."); res.json({ ...settings, backupDirectory: backupsDir });
} catch (error) { next(error); } } catch (error) { next(error); }
}); });
app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } }); // Pre-prune snapshots (pre-prune-*.sqlite) are safety copies taken before every log prune, scheduled or
// manual. They are not real backups: they never appear in the Backup & Restore list and can't be
// deleted from there (only .sgbackup files can). Without cleanup they accumulate forever and silently
// inflate the Storage breakdown on the System tab. Keep only the most recent few after each prune.
async function cleanupOldPruneSnapshots(keep = 3) {
try {
const names = (await fsp.readdir(backupsDir)).filter(name => name.startsWith("pre-prune-") && name.endsWith(".sqlite"));
const withStats = await Promise.all(names.map(async name => ({ name, mtime: (await fsp.stat(path.join(backupsDir, name))).mtimeMs })));
withStats.sort((a, b) => b.mtime - a.mtime);
for (const item of withStats.slice(keep)) await fsp.rm(path.join(backupsDir, item.name), { force: true });
} catch (error) { console.warn("Could not clean up old pre-prune snapshots:", error.message); }
}
app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); await cleanupOldPruneSnapshots(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } });
app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } }); app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } });
app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } }); app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } });
app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.defaultSite = { mode:"themed404", redirectUrl:"", redirectCode:302, preservePath:true, title:"Route not found", message:"The gateway is responding, but this address has not been configured.", customHtml:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"complete", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } }); app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.defaultSite = { mode:"themed404", redirectUrl:"", redirectCode:302, preservePath:true, title:"Route not found", message:"The gateway is responding, but this address has not been configured.", customHtml:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"complete", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } });
@@ -2363,10 +2650,17 @@ app.listen(adminPort, "0.0.0.0", () => {
if (adminPassword === "change-this-password") console.warn("WARNING: Change ADMIN_PASSWORD before exposing the dashboard."); if (adminPassword === "change-this-password") console.warn("WARNING: Change ADMIN_PASSWORD before exposing the dashboard.");
}); });
setTimeout(() => checkAllProxies().catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref(); setTimeout(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref();
setInterval(() => checkAllProxies().catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref(); setTimeout(() => cleanupOldPruneSnapshots().catch(error => console.warn("Startup pre-prune snapshot cleanup failed:", error.message)), 2000).unref();
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(); 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(); 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();
setTimeout(refreshDataDirSizeCache, 5000).unref();
setInterval(refreshDataDirSizeCache, 60000).unref();
// --- Scheduled jobs: automatic backups, log pruning, public IP checks, graceful shutdown --------------------------------- // --- Scheduled jobs: automatic backups, log pruning, public IP checks, graceful shutdown ---------------------------------
@@ -2386,10 +2680,10 @@ async function runScheduledBackup() {
} }
setTimeout(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 5000).unref(); setTimeout(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 5000).unref();
setInterval(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 15 * 60000).unref(); setInterval(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 15 * 60000).unref();
async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } } async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); await cleanupOldPruneSnapshots(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } }
setInterval(() => runScheduledPruning(), 15 * 60000).unref(); setInterval(() => runScheduledPruning(), 15 * 60000).unref();
setTimeout(() => importAccessLogsToSqlite(), 8000).unref(); setTimeout(() => importAccessLogsToSqlite().then(() => { lastAccessLogImportAt = new Date().toISOString(); }).catch(error => console.warn("Access-log import failed:", error.message)), 8000).unref();
setInterval(() => importAccessLogsToSqlite(), 30000).unref(); setInterval(() => importAccessLogsToSqlite().then(() => { lastAccessLogImportAt = new Date().toISOString(); }).catch(error => console.warn("Access-log import failed:", error.message)), 30000).unref();
async function checkPublicIp() { async function checkPublicIp() {
try { try {
+6 -2
View File
@@ -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 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 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 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 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 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)); 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));
@@ -75,6 +76,8 @@ export async function openStorage(dataDir, backupsDir) {
CREATE INDEX IF NOT EXISTS backup_events_instance_created ON backup_events(instance_id,created_at DESC); CREATE INDEX IF NOT EXISTS backup_events_instance_created ON backup_events(instance_id,created_at DESC);
`); `);
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ } try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
try { db.exec("ALTER TABLE api_tokens ADD COLUMN icon TEXT"); } catch { /* Column already exists. */ }
try { db.exec("ALTER TABLE api_tokens ADD COLUMN icon_slug TEXT"); } catch { /* Column already exists. */ }
const timestamp = now(); const timestamp = now();
db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp); db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp);
db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp); db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp);
@@ -178,11 +181,12 @@ export async function openStorage(dataDir, backupsDir) {
} }
// --- API tokens. Dedicated table (not the generic JSON-collection pattern) because // --- API tokens. Dedicated table (not the generic JSON-collection pattern) because
// every authenticated API request looks a token up by its SHA-256 hash. // every authenticated API request looks a token up by its SHA-256 hash.
function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); } function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,icon,icon_slug AS iconSlug,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); }
function createApiToken(row, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO api_tokens(id,instance_id,name,token_hash,prefix,owner_user_id,scope,session_version,created_at,last_used_at,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,NULL,?,NULL)").run(row.id, instanceId, String(row.name), String(row.tokenHash), String(row.prefix), String(row.ownerUserId), row.scope === "read-only" ? "read-only" : "full", row.sessionVersion || null, now(), row.expiresAt || null); return listApiTokens(instanceId).find(item => item.id === row.id) || null; } function createApiToken(row, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO api_tokens(id,instance_id,name,token_hash,prefix,owner_user_id,scope,session_version,created_at,last_used_at,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,NULL,?,NULL)").run(row.id, instanceId, String(row.name), String(row.tokenHash), String(row.prefix), String(row.ownerUserId), row.scope === "read-only" ? "read-only" : "full", row.sessionVersion || null, now(), row.expiresAt || null); return listApiTokens(instanceId).find(item => item.id === row.id) || null; }
function findApiTokenByHash(tokenHash, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,session_version AS sessionVersion,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? AND token_hash=?").get(instanceId, String(tokenHash)) || null; } function findApiTokenByHash(tokenHash, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,session_version AS sessionVersion,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? AND token_hash=?").get(instanceId, String(tokenHash)) || null; }
function revokeApiToken(id, instanceId = LOCAL_INSTANCE_ID) { return Number(db.prepare("UPDATE api_tokens SET revoked_at=? WHERE instance_id=? AND id=? AND revoked_at IS NULL").run(now(), instanceId, id).changes || 0) > 0; } function revokeApiToken(id, instanceId = LOCAL_INSTANCE_ID) { return Number(db.prepare("UPDATE api_tokens SET revoked_at=? WHERE instance_id=? AND id=? AND revoked_at IS NULL").run(now(), instanceId, id).changes || 0) > 0; }
function touchApiToken(id, instanceId = LOCAL_INSTANCE_ID) { db.prepare("UPDATE api_tokens SET last_used_at=? WHERE instance_id=? AND id=?").run(now(), instanceId, id); } function touchApiToken(id, instanceId = LOCAL_INSTANCE_ID) { db.prepare("UPDATE api_tokens SET last_used_at=? WHERE instance_id=? AND id=?").run(now(), instanceId, id); }
function setApiTokenIcon(id, { icon, iconSlug }, instanceId = LOCAL_INSTANCE_ID) { const changes = db.prepare("UPDATE api_tokens SET icon=?, icon_slug=? WHERE instance_id=? AND id=?").run(icon || null, iconSlug || null, instanceId, id).changes; return changes > 0 ? listApiTokens(instanceId).find(item => item.id === id) || null : null; }
// --- Backup history. Independent of what is on disk, so deleted backups and failed // --- Backup history. Independent of what is on disk, so deleted backups and failed
// attempts stay visible in the timeline. // attempts stay visible in the timeline.
function recordBackupEvent(event, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO backup_events(instance_id,type,filename,backup_type,size_bytes,actor_user_id,created_at,safety_backup_filename,status,error_message) VALUES(?,?,?,?,?,?,?,?,?,?)").run(instanceId, String(event.type), event.filename || null, event.backupType || null, event.sizeBytes ?? null, event.actorUserId || null, event.createdAt || now(), event.safetyBackupFilename || null, event.status === "failed" ? "failed" : "success", event.errorMessage ? String(event.errorMessage).slice(0, 500) : null); } function recordBackupEvent(event, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO backup_events(instance_id,type,filename,backup_type,size_bytes,actor_user_id,created_at,safety_backup_filename,status,error_message) VALUES(?,?,?,?,?,?,?,?,?,?)").run(instanceId, String(event.type), event.filename || null, event.backupType || null, event.sizeBytes ?? null, event.actorUserId || null, event.createdAt || now(), event.safetyBackupFilename || null, event.status === "failed" ? "failed" : "success", event.errorMessage ? String(event.errorMessage).slice(0, 500) : null); }
@@ -212,5 +216,5 @@ export async function openStorage(dataDir, backupsDir) {
} }
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; } function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); } const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, recordBackupEvent, listBackupEvents, close: () => db.close() }; return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, setApiTokenIcon, recordBackupEvent, listBackupEvents, close: () => db.close() };
} }