Compare commits

..

88 Commits

Author SHA1 Message Date
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
marvin b8dca3e397 Fix System tab: populate status sections on first render, add spacing between stacked cards (v0.16.3) 2026-09-18 16:32:17 -04:00
marvin 17c059e215 Fix nested dashboard-panel double-border and show actual access address instead of internal admin port (v0.16.2) 2026-09-18 16:00:42 -04:00
marvin f33fa99686 Add missing BACKUP_PASSWORD status row to the System tab (v0.16.1) 2026-09-18 15:53:04 -04:00
marvin 9c1426d276 Add System tab; fix settings-revert bug, encryption-status messaging, JS caching, native dropdown theming, and backup panel presentation (v0.16.0) 2026-09-18 15:39:58 -04:00
marvin ca7136afb8 Fix dashboard resync button wiring, attention color consistency, drift false-positives, and remove redundant Gateway Defaults callout (v0.15.2) 2026-09-18 13:51:55 -04:00
marvin 005ebac2b5 Fix drift-alert usability, dashboard signal accuracy, dropdown theming, and toast/dialog stacking; document v0.15.0 and v0.15.1 (v0.15.1) 2026-09-18 13:21:11 -04:00
marvin 59f0708382 Add REST API tokens, backup history, Docker container picker, Caddy config popout, Performance overhaul, and dashboard tile unification (v0.15.0) 2026-09-18 12:14:14 -04:00
marvin 968066d39a Add config-drift detection, backup-encryption readiness gating, dialog cleanup (v0.14.0) 2026-09-18 11:43:43 -04:00
marvin b72afdcee3 Remove light theme; complete color/spacing/radius design tokens; section styles.css (v0.13.0) 2026-09-17 20:58:27 -04:00
marvin 43ba59b302 Move to semver going forward; tokenize border-radius and font-size literals (v0.12.0) 2026-09-16 18:31:26 -04:00
marvin 00990d5008 Fix recovery codes being unreadable (light-on-light) when OS is in light mode but app theme is dark (v0.11.120) 2026-09-16 18:04:13 -04:00
marvin e0463525ef Fix recovery-codes dialog not appearing in Safari after 2FA setup (v0.11.119) 2026-09-16 17:56:30 -04:00
marvin 5417d5eeed Add administrator override for a locked-out user's 2FA; update README/ROADMAP (v0.11.118) 2026-09-16 17:42:26 -04:00
marvin 2890be6129 Filter performance table by host, format avg response times in seconds, differentiate toast error styling (v0.11.117) 2026-09-16 15:50:02 -04:00
marvin 964b1dd2de Fix error-breakdown popup: remove close X, fix broken data handoff (v0.11.116) 2026-09-16 15:34:48 -04:00
marvin 26e2660a03 Use design tokens for spacing/radius/font-size in error-breakdown popup (v0.11.115) 2026-09-16 15:23:25 -04:00
marvin 9c20253fa4 Replace inline error-breakdown expand with a themed popup (v0.11.114) 2026-09-16 15:21:54 -04:00
marvin 49d8bd83e6 Click-to-expand error breakdown on Performance table (v0.11.113) 2026-09-16 15:11:59 -04:00
marvin 7109967f67 Update README/ROADMAP for v0.11.112 and current feature set 2026-09-16 15:11:47 -04:00
marvin e26c1a3dec Hide misleading preview caption for Abort/Redirect modes (v0.11.112) 2026-09-16 14:57:18 -04:00
marvin f51067bceb Fix blank page caused by unguarded download-support listener (v0.11.110) 2026-09-16 12:34:41 -04:00
marvin 31e50d0e14 Fix blank page caused by unguarded download-support listener (v0.11.110) 2026-09-16 12:26:10 -04:00
marvin 98b79042bf Hide Gateway diagnostics from view, keep referenceable (v0.11.109) 2026-09-16 12:21:32 -04:00
marvin 72afecd539 Restyle Gateway diagnostics as a proper card (v0.11.108) 2026-09-16 12:08:39 -04:00
marvin 180280791a Fix Gateway diagnostics showing on every admin tab (v0.11.107) 2026-09-16 11:57:37 -04:00
marvin 753fb33d52 Update Documentation manual to match Security & updates consolidation (v0.11.106) 2026-09-16 11:43:49 -04:00
marvin 7846342412 Consolidate Security & updates into Certificates and Backup & restore (v0.11.105) 2026-09-16 11:30:39 -04:00
marvin 2d0e9989ed Add design token foundation (spacing/radius/type scale); fix indicator-bar spacing inconsistency across views (v0.11.104) 2026-09-16 11:24:56 -04:00
marvin 92f3430892 Fix storage.js SQL corruption from v0.11.103 comments; bump to v0.11.103.1 2026-09-16 11:09:10 -04:00
marvin 5c84e6cf73 Revert storage.js: v0.11.103 comments broke a SQL template literal 2026-09-16 11:02:08 -04:00
marvin cb1bdc6aca Annotate server.js, storage.js, app.js, features.js, and index.html with section/function comments 2026-09-16 10:54:49 -04:00
marvin b62f27f415 Give Proxy Hosts their own empty-state glyph 2026-09-16 09:13:17 -04:00
marvin e39e1c3245 Add full compose files and ZimaOS manifest 2026-09-16 08:29:45 -04:00
marvin f1e89af839 Update README for v0.11.101 2026-09-15 22:01:01 -04:00
marvin 609037fc2c Update README for v0.11.101 2026-09-15 21:55:01 -04:00
marvin 399ca4ddaf Allow renaming a Hosted Site the same way Proxy Hosts already can (v0.11.101) 2026-09-15 19:11:25 -04:00
marvin ac88b3b924 More breathing room around the brand mark; center themed pages (v0.11.100) 2026-09-15 16:45:20 -04:00
marvin 1867bdea81 New icon and wordmark brand artwork (v0.11.99) 2026-09-15 16:34:58 -04:00
marvin d9028aa8dc Fix missing dropdown arrow on Backup & Restore selects; catch package.json version up to v0.11.97 (v0.11.98) 2026-09-15 16:04:28 -04:00
marvin 4050b004bd Stop the Logs & Retention preview poll from hitting the API before login, clobbering the username field (v0.11.97) 2026-09-15 15:49:34 -04:00
marvin 1aee27b539 Fix Certificates section description text-size inconsistency (v0.11.96) 2026-09-15 10:23:08 -04:00
marvin a6060627d9 Static Administration tab bar; backup page UX improvements (v0.11.95) 2026-09-15 09:58:57 -04:00
marvin 13ff035fae Login-sized brand mark on themed pages; fix duplicate backup creation and auto-download (v0.11.93) 2026-09-15 09:46:07 -04:00
16 changed files with 3556 additions and 763 deletions
+98 -219
View File
@@ -1,295 +1,174 @@
<div align="center">
<img src="src/public/icon.png" alt="Site Gateway icon" width="180">
<h1>Site Gateway</h1>
<p>
<img src="src/public/site-gateway-icon-approved.png" alt="Site Gateway icon" width="88">
</p>
<p>
<img src="src/public/site-gateway-wordmark-approved.png" alt="Site Gateway" width="280">
</p>
<p><strong>Host. Proxy. Secure.</strong></p>
<p>A friendly, self-hosted gateway for websites, applications, domains, and automatic HTTPS.</p>
<p>A friendly, self-hosted gateway for homelabs and small teams — publish static sites, reverse-proxy your apps, forward raw TCP/UDP streams, and manage TLS and access from one calm dashboard.</p>
<p>
<a href="https://github.com/mfwadejr/site-gateway2/actions/workflows/container.yml"><img alt="Container build" src="https://github.com/mfwadejr/site-gateway2/actions/workflows/container.yml/badge.svg"></a>
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Public alpha" src="https://img.shields.io/badge/status-public%20alpha-FFBF69">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.50-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
<a href="#what-you-get">What you get</a> ·
<a href="#quick-start">Quick start</a> ·
<a href="#domains-proxy-hosts-and-tls">Domains &amp; TLS</a> ·
<a href="#unraid-alpha-install">Unraid</a> ·
<a href="#zimaos-alpha-install">ZimaOS</a> ·
<a href="INSTALL-v0.9.0-alpha.1.md">v0.9 installation guide</a> ·
<a href="#configuration">Configuration</a> ·
<a href="#unraid">Unraid</a> ·
<a href="#zimaos">ZimaOS</a> ·
<a href="ROADMAP.md">Roadmap</a>
</p>
</div>
---
Site Gateway gives a home server one clear control panel for two jobs: publishing uploaded static sites and routing domains to applications already running on your network. Caddy handles the gateway, certificates, renewals, redirects, compression, and WebSocket forwarding behind the scenes.
## Why Site Gateway
| Publish | Route | Protect | Operate |
Most homelabs end up with the same problem: a handful of self-hosted apps, a couple of static sites, maybe a game server, and no clean way to expose any of it without hand-editing Nginx or Caddy configs every time something changes. Site Gateway is a single container that gives that setup one dashboard: point a domain at it, pick what you're publishing, and it handles routing, certificates, and renewal behind the scenes with [Caddy](https://caddyserver.com/).
It's intentionally narrower than a general-purpose proxy manager. You describe *what* you want (a site, a proxy target, a redirect, a raw port forward) and Site Gateway writes and safely reloads the underlying gateway configuration — no Caddyfile required.
## What you get
| Hosted Sites | Proxy Hosts | Redirect Hosts | Streaming Hosts |
| --- | --- | --- | --- |
| Upload a ZIP or `index.html` | Proxy domains to LAN apps or containers | Automatic HTTPS certificates and renewal | Enable, disable, replace, and delete from one dashboard |
| Assign direct testing ports | Host multiple domains on ports 80/443 | Optional HSTS and HTTPS redirects | Persistent `/data` storage with PUID/PGID support |
| 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 |
> [!NOTE]
> Site Gateway is intentionally simpler than a general-purpose proxy manager. You provide the site or destination; the guided interface writes and safely reloads the gateway configuration.
- **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, and throughput at a glance, plus a live resource panel (CPU, memory, swap, disk, network, uptime) reading real container-scoped cgroup v2 stats, not host-wide numbers, and auto-refreshing while the page is open.
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes, plus an administrator-side override to disable a locked-out user's 2FA when they've lost their authenticator and used up their recovery codes.
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
- **API access tokens** — issue scoped (full-access or read-only), optionally expiring bearer tokens for scripts and integrations, revocable at any time.
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
- **Performance and logs** — per-domain request throughput, response times, and rotating access/activity logs, including a System page (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.
## Alpha features
- Password-protected, responsive dashboard with live gateway health
- Hosted-site, proxy-host, TLS-domain, and attention totals at a glance
- Runtime uptime, memory, persistent-data size, disk space, and installed versions
- Recent configuration activity for the current container session
- Confirmed Caddy, HTTP port 80, and HTTPS port 443 health checks with manual and automatic refresh
- Searchable Dashboard Icons picker with validated local storage under `/data/icons`
- Consistent two-letter icon fallbacks for hosted sites and proxy hosts
- Create a site from a ZIP archive or a single `index.html`
- One independently enabled/disabled port per site
- Caddy gateway on ports 80 and 443
- Domain routing and automatic HTTPS for hosted sites
- Reverse proxy hosts for containers, LAN services, and applications
- Redirect Hosts with 301, 302, 307, and 308 responses and optional path preservation
- Reusable Access Lists with LAN/CIDR rules and a themed username/password sign-in page
- Collapsible Proxy Host controls for custom locations, headers, compression, upstream TLS, health expectations, and expert Caddy snippets
- Public, internal, HTTP-only, and uploaded custom-certificate modes
- Automatic certificate renewal and HTTP-to-HTTPS redirects
- Configurable themed welcome, 404, redirect, no-response, and custom-HTML fallback pages
- Integrated, searchable documentation with real-world setup examples
- Administrator workspace for users, gateway defaults, security guidance, and backup/restore
- Downloadable, importable, scheduled, retained, and optionally encrypted `.sgbackup` archives
- Replace a site's files without recreating it
- Delete sites and their stored files
- Persistent configuration and uploads under `/data`
- Built-in transactional SQLite configuration database at `/data/database/site-gateway.sqlite`
- Unified certificate storage under `/data/certificates` and fixed backup storage under `/data/backups`
- Path traversal protection for ZIP extraction and a 250 MB upload limit
- Clean shutdown and automatic site restart after a container restart
Hosted uploads remain static-only (HTML, CSS, JavaScript, images, fonts, and downloads). Dynamic applications can be connected as proxy hosts. 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.
## Quick start
Requirements: Docker Engine with Docker Compose.
Requirements: Docker Engine with Docker Compose, and ports 80/443 free on the host (plus 8080 for the dashboard).
1. Edit `compose.yaml` and replace `change-this-password` with a strong password.
2. From this folder, run:
1. Copy `.env.example` to `.env` and set `ADMIN_PASSWORD` and `SESSION_SECRET`.
2. Pull and start the published image:
```bash
docker compose up -d --build
docker compose -f compose.release.yaml pull
docker compose -f compose.release.yaml up -d
```
3. Open `http://YOUR-SERVER-IP:8080`.
4. Sign in with `admin` and the password you chose.
5. Select **New site**, provide a name and unused port, then upload either:
- a ZIP with `index.html` at its root; or
- a single `index.html` file.
6. Open the site from its arrow button or visit `http://YOUR-SERVER-IP:PORT`.
3. Open `http://YOUR-SERVER-IP:8080` and sign in with `admin` and the password you set.
4. Finish first-time setup (you'll be asked to confirm or change the display name, username, and password).
5. Create your first route from the dashboard — Hosted, Proxy, Redirect, or Streaming.
The included Compose file publishes site ports 90009099. Docker cannot add a host port to an already-running container, so any site port must be included in the published range. Change `SITE_PORT_MIN`, `SITE_PORT_MAX`, and the Compose `ports` range together before starting the container if you want a different range.
Prefer to build from source instead of pulling the image? Use `compose.yaml` and `docker compose up -d --build`.
For domain routing and automatic certificates, point the domain's DNS record at this server and forward public ports 80 and 443 to the container. If another reverse proxy already owns those ports, stop it or map Site Gateway to temporary alternate host ports for LAN testing; public ACME issuance will not work until 80/443 traffic reaches Site Gateway.
The included Compose files publish site ports 90009099 for direct-LAN access to Hosted Sites. Docker can't add a host port to an already-running container, so change `SITE_PORT_MIN`, `SITE_PORT_MAX`, and the Compose `ports` range together, before starting the container, if you want a different range. The same applies to Streaming Hosts — publish the TCP/UDP port you plan to use before creating the route in the dashboard.
For domain routing and automatic certificates, point the domain's DNS record at this server and forward public ports 80 and 443 to the container. If another reverse proxy already owns those ports, stop it or use temporary alternate host ports for LAN testing — public ACME issuance won't work until 80/443 traffic actually reaches Site Gateway.
## Domains, proxy hosts, and TLS
Use **Hosted sites** for uploaded files. A domain is optional; when present, Caddy serves the site on ports 80/443 and automatically obtains and renews a public certificate. Direct site ports remain available for LAN testing.
Use **Hosted Sites** for uploaded files. A domain is optional; when present, Caddy serves the site on ports 80/443 and automatically obtains and renews a public certificate. Direct site ports remain available for LAN testing.
Use **Proxy hosts** to connect a domain to an existing application such as `http://192.168.1.20:3000` or another container name and port. Caddy supplies the normal forwarded headers and supports WebSocket upgrades automatically.
Use **Proxy Hosts** to connect a domain to an existing application, such as `http://192.168.1.20:3000` or another container's name and port. Caddy supplies the standard forwarded headers and supports WebSocket upgrades automatically.
Automatic HTTPS requires valid public DNS and inbound access to port 80 or 443. Caddy renews certificates automatically before expiration. HSTS is optional and should only be enabled after HTTPS works reliably.
Use **Streaming Hosts** for anything that isn't HTTP — game servers, SSH, or other raw TCP/UDP services. These need their port published in Compose up front, since Docker can't add ports to a running container.
## Install from the published image
Automatic HTTPS requires valid public DNS and inbound access to port 80 or 443. HSTS is optional and should only be enabled after HTTPS is confirmed working.
Each push to `main` automatically publishes `ghcr.io/mfwadejr/site-gateway2:latest` for both Intel/AMD and ARM64 servers. Copy `.env.example` to `.env`, replace the password and session secret, then run:
## Configuration
```bash
docker compose -f compose.release.yaml pull
docker compose -f compose.release.yaml up -d
```
| Variable | Default | Purpose |
|---|---:|---|
| `ADMIN_USERNAME` | `admin` | Bootstrap dashboard login name |
| `ADMIN_PASSWORD` | — | Bootstrap dashboard password; **required**, always change it |
| `SESSION_SECRET` | — | **Required.** Any random string; rotating it signs everyone out |
| `ADMIN_PORT` | `8080` | Dashboard port inside the container |
| `SITE_PORT_MIN` / `SITE_PORT_MAX` | `9000` / `9099` | Direct-LAN port range Hosted Sites can bind to |
| `DATA_DIR` | `/data` | Persistent state location |
| `DATA_DIR_LIMIT_GB` | empty | Optional display-only allowance for the System tab's Disk stat (e.g. a smaller dedicated share); usage/free space still come from the real volume |
| `BACKUP_PASSWORD` | empty | Encryption password used only when encrypted scheduled backups are enabled |
| `PUID` / `PGID` | `1000` / `1000` | User/group the container writes files as (Unraid: `99`/`100`) |
| `ACME_EMAIL` | empty | Optional certificate account email |
To upgrade later:
At startup, the container creates the complete `/data` hierarchy, applies `PUID`/`PGID` ownership, then drops root privileges. Configuration lives in SQLite at `/data/database/site-gateway.sqlite`; hosted files live under `/data/sites`; backups under `/data/backups`; certificates under `/data/certificates`.
```bash
docker compose -f compose.release.yaml pull
docker compose -f compose.release.yaml up -d
```
## Unraid
This recreates only the application container. Uploaded sites remain in the persistent data mount.
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.
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`.
4. Set `PUID=99` and `PGID=100` so the container writes to `/data` as the `nobody`/`users` account Unraid expects.
5. Set `ADMIN_PASSWORD` and `SESSION_SECRET`, then start the container and open `http://UNRAID-IP:8080`.
## ZIP layout
For automatic image-based upgrades, Unraid's **Update Container** action pulls the newest `latest` image; if you use Watchtower, `compose.release.yaml` includes its opt-in label.
Preferred:
## ZimaOS
```text
my-site.zip
├── index.html
├── styles.css
├── app.js
└── images/
└── logo.png
```
The simplest path is [`compose.zimaos.yaml`](compose.zimaos.yaml) — a ready-to-import file with the `x-casaos` metadata ZimaOS's app installer and App Store use for the icon, title, and port mapping.
A ZIP containing one top-level folder is also accepted; Site Gateway unwraps that folder automatically.
1. In ZimaOS, go to **Docker → Install a Customized App**, and paste or select `compose.zimaos.yaml`.
2. Before starting it, edit `ADMIN_PASSWORD` and `SESSION_SECRET` in the environment fields.
3. Confirm the data path — it defaults to `/DATA/AppData/site-gateway` — and start the app.
4. Open `http://ZIMAOS-IP:8080`.
## Unraid alpha install
Prefer a plain Compose file instead? `compose.yaml` (build from source) and `compose.release.yaml` (pull the published image) both work the same way:
### Option A: Compose Manager
1. Install **Compose Manager** from Community Applications if it is not already present.
2. Copy this project folder to `/mnt/user/appdata/site-gateway/app`.
3. In `compose.yaml`, change the volume to `/mnt/user/appdata/site-gateway/data:/data`.
4. Set a strong `ADMIN_PASSWORD`. Optionally set a long random `SESSION_SECRET`.
5. Add the stack in Compose Manager and choose **Compose Up**.
6. Open `http://UNRAID-IP:8080`.
For automatic image-based upgrades, use `compose.release.yaml` instead. Unraid's **Update Container** action can pull the newest `latest` image. If you already use Watchtower, the release Compose file includes its opt-in label.
### Option B: build from the Unraid terminal
```bash
cd /mnt/user/appdata/site-gateway/app
docker compose up -d --build
```
If Unraid reports a port conflict, change the admin port mapping's left side (for example `8180:8080`) or choose a different site-port range. Allow the selected site ports through any LAN firewall.
## ZimaOS alpha install
1. Copy this folder into ZimaOS storage, for example `/DATA/AppData/site-gateway/app`.
2. Change the Compose volume to `/DATA/AppData/site-gateway/data:/data`.
3. Set a strong `ADMIN_PASSWORD` and optionally `SESSION_SECRET`.
4. In the ZimaOS app interface, use its custom app / Compose import option and paste or select `compose.yaml`. If that option is unavailable in your release, use the terminal:
1. Copy this folder into ZimaOS storage, e.g. `/DATA/AppData/site-gateway/app`.
2. Point the Compose volume at `/DATA/AppData/site-gateway/data:/data`.
3. Set `ADMIN_PASSWORD` and `SESSION_SECRET` (and `PUID`/`PGID` if needed — ZimaOS typically uses `1000:1000`).
4. Import through ZimaOS's custom app / Compose import option, or run it from the terminal:
```bash
cd /DATA/AppData/site-gateway/app
docker compose up -d --build
```
5. Open `http://ZIMAOS-IP:8080`.
For simple upgrades, import `compose.release.yaml`; use ZimaOS's container update/recreate action whenever a new image is published. The `/data` mount keeps all sites during replacement.
## Migrating from Web Server
The product, repository, image, and default container are now named Site Gateway. Existing data does not need to move. Stop and remove the old container, then run the new image while mounting the existing folder:
```bash
docker stop web-server
docker rm web-server
docker pull ghcr.io/mfwadejr/site-gateway2:latest
docker run -d --name site-gateway --restart unless-stopped \
-p 8080:8080 -p 80:80 -p 443:443 -p 9000-9099:9000-9099 \
-v /DATA/AppData/web-server:/data \
-e ADMIN_USERNAME=admin \
-e ADMIN_PASSWORD='YOUR_EXISTING_PASSWORD' \
-e SESSION_SECRET='YOUR_EXISTING_SESSION_SECRET' \
-e PUID=1000 -e PGID=1000 \
ghcr.io/mfwadejr/site-gateway2:latest
```
After confirming the sites appear, you may keep the legacy host folder or rename it to `/DATA/AppData/site-gateway` while the container is stopped and update the mount accordingly. Unraid users should retain `/mnt/user/appdata/web-server` as the template's Data path for the first upgraded launch.
## Configuration
| Variable | Default | Purpose |
|---|---:|---|
| `ADMIN_USERNAME` | `admin` | Dashboard login name |
| `ADMIN_PASSWORD` | `change-this-password` | Dashboard password; always change it |
| `SESSION_SECRET` | derived | Optional stable signing secret for login sessions |
| `ADMIN_PORT` | `8080` | Dashboard port inside the container |
| `SITE_PORT_MIN` | `9000` | Lowest allowed site port |
| `SITE_PORT_MAX` | `9099` | Highest allowed site port |
| `DATA_DIR` | `/data` | Persistent state location |
| `BACKUP_PASSWORD` | empty | Encryption password used only when encrypted scheduled backups are enabled |
| `PUID` | `1000` | UID that owns and runs against persistent files |
| `PGID` | `1000` | GID that owns and runs against persistent files |
| `ACME_EMAIL` | empty | Optional certificate account email |
At startup, the container creates the complete `/data` hierarchy, applies `PUID`/`PGID` ownership, and then drops root privileges. Configuration is stored in SQLite, hosted files remain in `/data/sites`, backups use `/data/backups`, uploaded certificates use `/data/certificates/custom`, and Caddy owns `/data/certificates/managed`. With the ZimaOS bind mount, these appear under `/DATA/AppData/site-gateway` on the host. Unraid commonly uses `PUID=99` and `PGID=100`; ZimaOS typically uses `1000:1000`.
5. Open `http://ZIMAOS-IP:8080`. Use ZimaOS's container update/recreate action whenever a new image is published — the `/data` mount keeps all sites during replacement.
## Backup and update
Open **Administration → Backup & restore** to create a Configuration or Complete backup. Manual backups download to the browser. Scheduled backups are stored under `/data/backups`, retained according to the interface setting, and can use AES-256-GCM encryption when `BACKUP_PASSWORD` is configured. A Complete backup contains a consistent SQLite snapshot, portable JSON recovery data, hosted files, local icons, custom fallback assets, and both custom and Caddy-managed certificate storage; logs are optional. Because certificate backups contain private keys, encryption is strongly recommended.
Open **Administration → Backup & restore** to create a Configuration or Complete backup. Manual backups download to the browser; scheduled backups are stored under `/data/backups` and can be AES-256-GCM encrypted when `BACKUP_PASSWORD` is set. A Complete backup contains a consistent SQLite snapshot, portable JSON recovery data, hosted files, local icons, custom fallback assets, and certificate storage. Because certificate backups contain private keys, encryption is strongly recommended.
Before restoring, Site Gateway checks the archive manifest and creates a complete pre-restore safety backup. It then reloads persisted state and validates the resulting Caddy configuration. Store important backups on a separate disk or NAS share—copies in the same appdata volume do not protect against disk failure.
Before restoring, Site Gateway checks the archive manifest, creates a complete pre-restore safety backup, then reloads and validates the resulting configuration.
To rebuild after pulling a new version:
To upgrade:
```bash
docker compose up -d --build
docker compose -f compose.release.yaml pull
docker compose -f compose.release.yaml up -d
```
Your sites remain intact because they live in the mounted data directory.
## Publishing updates
The GitHub Actions workflow builds and publishes a fresh multi-architecture container whenever code is pushed to `main`. Alpha release tags publish an exact version and the moving `alpha` channel. For example, `v0.9.0-alpha.1` publishes `ghcr.io/mfwadejr/site-gateway2:0.9.0-alpha.1` and `ghcr.io/mfwadejr/site-gateway2:alpha`. The package starts private if the GitHub account's package defaults require it; make the `site-gateway2` package public in GitHub package settings so Unraid and ZimaOS can pull without credentials.
### Monitoring in v0.5.0-alpha.1
- Certificate inventory shows issuer, expiration date, days remaining, provisioning state, and the last certificate-file update reported by Caddy.
- Dashboard alerts call out certificates within 30 days of expiration and unreachable proxy upstreams.
- Enabled proxy targets are checked every 60 seconds with a four-second timeout; status, HTTP response, latency, and recent in-memory history are available to the dashboard.
- Caddy access logs are stored as rotating JSON files under `/data/logs` and displayed without request headers. Gateway activity and errors are also appended to `/data/logs/activity.jsonl`.
`v0.5.0-alpha.2` clarifies that a missing stored certificate is **not detected**, rather than claiming issuance is actively provisioning, and includes a consistency pass for dashboard indicators, cards, and log controls.
### Users and roles in v0.6.0-alpha.1
- The environment-defined administrator becomes the initial persistent Administrator on first startup after upgrading.
- Administrators can create users, assign Administrator or Standard User roles, reset passwords, disable accounts, and archive or restore accounts.
- Standard Users have read-only access to dashboard health, hosted sites, proxy hosts, certificates, and logs. Per-host ownership and granular permissions are planned for a later release.
- Passwords are stored as salted scrypt hashes in the SQLite database; plaintext passwords are never written to disk.
- Site Gateway prevents removal of the final active Administrator and blocks users from disabling or archiving their own active session.
### Gateway management in v0.7.0-alpha.1
- Administration and Documentation appear directly above the installed-version divider; Administration is role-restricted.
- Proxy Hosts support multiple custom locations using `path | destination | strip-or-preserve`, request/response headers, upstream TLS controls, custom certificates, Access Lists, compression, and configurable health checks.
- Access List credentials are stored as salted password hashes and presented through a Site Gateway-themed login form. Network rules accept exact IP addresses, CIDR ranges, or Caddy's `private_ranges` token.
- Redirect Hosts and the configurable Default Site compile to native Caddy routes and are validated before reload.
- Expert Caddy snippets are administrator-only, size-limited, screened against global directives, and validated as part of the complete generated configuration.
### Storage foundation in v0.8.0-alpha.1
- SQLite is built into the container and stores configuration at `/data/database/site-gateway.sqlite`; no external database container or port is required.
- A seeded Local Gateway instance scopes every stored entity in preparation for future multi-instance management.
- Existing JSON installations are imported once into SQLite after a complete migration backup is written to `/data/backups`; original JSON snapshots remain under `/data/migrations`.
- Failed first-time imports remove the incomplete database so the migration safely retries after the source problem is corrected.
- Caddy-managed certificates and internal CA data live under `/data/certificates/managed`; uploaded certificates live under `/data/certificates/custom`; public exports are reserved under `/data/certificates/exports`.
- Backups contain a consistent SQLite snapshot, portable JSON recovery records, checksums, and optional complete filesystem content.
### First login in v0.8.0-alpha.2
- Fresh installations explain that the administrator credentials supplied to Docker are bootstrap credentials.
- After the first successful sign-in, the administrator must confirm or change the display name, username, and password before opening the dashboard.
- Completing setup rotates the account session identity and requires one final sign-in with the finalized credentials.
- Existing installations are treated as already configured and are not interrupted by the new workflow.
### Trust and visibility in v0.9.0-alpha.1
- Certificate details include source, covered domains, issuer, validity, serial number, fingerprint, expiration, and the last detected file update.
- Configurable warning thresholds, 30-day and 7-day totals, uploaded-certificate mismatch detection, and linked dashboard alerts make certificate state actionable.
- On-demand diagnostics distinguish DNS, HTTP/HTTPS listener, TLS, and upstream failures for each configured domain.
- Administrators can download a redacted support report that excludes credentials, private keys, cookies, secrets, and expert configuration.
- Password minimums are eight characters throughout, and authentication forms are cleared after use.
This recreates only the application container — your sites, certificates, and configuration remain in the mounted data directory.
## Security notes
- Use unique bootstrap credentials during installation, then finalize the persistent administrator account during first-time setup.
- Keep the dashboard on a trusted LAN or behind a trusted HTTPS reverse proxy/VPN. The alpha dashboard itself serves plain HTTP.
- Do not expose the admin dashboard directly to the internet.
- Uploaded static JavaScript runs for visitors. Only publish files you trust.
- The container starts as root only to apply `PUID`/`PGID` ownership and grant Caddy the `cap_net_bind_service` capability, then drops both the Node app and Caddy to the unprivileged `PUID:PGID` user (default `1000:1000`) via `su-exec`. It does not require access to the Docker socket.
- Use a unique bootstrap password during installation, then finish first-time setup to finalize the persistent administrator account.
- Enable two-factor authentication on administrator accounts.
- Keep the dashboard on a trusted LAN or behind a trusted HTTPS reverse proxy/VPN — don't expose the admin dashboard directly to the internet.
- Uploaded static JavaScript runs for visitors; only publish files you trust.
- The container starts as root only to apply `PUID`/`PGID` ownership and grant Caddy `cap_net_bind_service`, then drops both the Node app and Caddy to the unprivileged `PUID:PGID` user. It does not require access to the Docker socket.
## Troubleshooting
- **Site shows Error:** another process probably owns its port. Check `docker logs site-gateway`, then recreate the site on a free published port.
- **Site cannot be reached:** confirm the port is within the published Compose range and allowed through the server firewall.
- **Permission denied under `/data`:** make the host data directory writable by UID/GID 1000, or adjust ownership to match your environment.
- **Permission denied under `/data`:** make the host data directory writable by the configured `PUID`/`PGID`.
- **Upload fails:** verify the file is below 250 MB and the extracted root contains `index.html`.
- **Dashboard port is busy:** change only the host side, such as `8180:8080`, then browse to port 8180.
- **Dashboard port is busy:** change only the host side, e.g. `8180:8080`, then browse to port 8180.
- **Streaming Host has no traffic:** confirm the TCP/UDP port is published in Compose *before* creating the route — Docker can't add ports to a running container.
## Alpha roadmap
## Roadmap
Good next additions are per-site access logs, certificate status reporting, drag-and-drop folder upload, rollback/history, health checks, access lists, and guided DNS diagnostics.
See [ROADMAP.md](ROADMAP.md) for what's shipped and what's next.
## License
+192 -124
View File
@@ -2,157 +2,225 @@
## Current release status
`v0.10.0-alpha.95.3` completes the initial configuration audit-log work. Alpha 96 is the integration and release-hardening phase; backup and restore validation is intentionally scheduled last.
`v0.12.0` marks a shift from the earlier per-fix `0.11.x` patch numbering to ordinary semantic versioning going forward — a minor bump for a real batch of changes, a patch bump for a targeted fix, rather than incrementing the same trailing number for every single change regardless of size. The product itself has moved well past the original alpha creation flow described in earlier versions of this document — Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts are all implemented, along with authentication, access control, certificates, backups, and full dashboard reporting. This document reflects what's actually shipped and what's genuinely still ahead.
`v0.13.0` is a real batch under that same convention, not a targeted fix, even though none of it changes what the app *does*: the in-app light theme has been removed entirely (the app is dark-only now, including the two visitor-facing themed pages -- the default-site 404/welcome/custom-HTML page and the Access-List sign-in page, both previously following the visitor's OS light/dark preference and now fixed dark for consistency with the rest of the app), the full color/spacing/radius design-token system begun in v0.12.0 has been completed (zero hardcoded color literals remain anywhere outside the token definitions), and `styles.css` has been restructured into commented, page-aligned sections matching the convention already used in `app.js`/`features.js`/`server.js`. A handful of small pre-existing bugs (a duplicate CSS custom property, some dead/duplicate rules, a decorative background glow that rendered incorrectly at certain aspect ratios) were also found and fixed along the way.
`v0.14.0` adds config-drift detection, a backup-encryption readiness check, and an app-wide dialog cleanup.
- **Configuration drift detection** — a background check every 10 minutes compares Caddy's live running configuration (via its admin API `/config/` endpoint) against what Site Gateway's saved routes would currently generate (via `/adapt`). If they disagree \u2014 for example after a manual edit to the Caddyfile outside the app, or a Caddy restart that didn't pick up the latest reload \u2014 a "Configuration drift" item appears in the dashboard's Needs Attention list, driven by a new `POST /api/gateway/resync` route that re-runs the normal Caddy sync and clears the flag.
- **Backup-encryption readiness** — the "Encrypt scheduled backups" checkbox no longer lets you configure something that will silently fail later. `/api/config` now reports whether the `BACKUP_PASSWORD` environment variable is actually set; the checkbox is disabled with an explanatory message when it isn't, and if it was previously saved as enabled and `BACKUP_PASSWORD` has since been removed, it shows a distinct warning instead of failing quietly at the next scheduled run.
- **Dialog cleanup** \u2014 every themed popout dialog's redundant "\u00d7" close button (in the dialog-heading row) has been removed app-wide; each dialog already has a working Cancel/Close button in its actions row, so this is pure de-duplication with no loss of function. New dialogs are expected to follow this pattern going forward.
`v0.15.0` ships the full set of features scoped alongside `v0.14.0` and deferred at the time — nothing here was cut:
- **REST API with issuable tokens** \u2014 admin-issued bearer tokens (full or read-only scope) for scripting against the Site Gateway API outside the browser session, bound to the issuing user's session version so a password reset/deactivation revokes them automatically.
- **Backup/restore history** \u2014 a durable, database-backed history of every backup, restore, and deletion (including failed attempts), shown as a human-readable timeline that never displays raw backup filenames.
- **Docker container picker** \u2014 an opt-in integration (gated on the Docker socket being mounted and readable) that lets Proxy/Streaming targets be picked from the host's running containers instead of typed by hand, using each container's Docker DNS name.
- **"View Caddy config" popout** \u2014 a read-only, prettified view of the exact Caddy configuration block generated for a given site, proxy, or redirect, built from the same code path that generates the real deployed config so it can never drift from it.
- **Performance screen overhaul** \u2014 clock-aligned time-axis labels, a y-axis unit, hover tooltips with error counts, p95 latency, bandwidth and unique-visitor columns, a 4xx/5xx-colored error breakdown, top-paths-per-host, and a slowest-requests panel.
- **Dashboard tile color unification** \u2014 normalizing all "normal count" tiles to a shared green baseline that reacts to warning/danger states the same way the existing Needs Attention tile does.
- Two pre-existing bugs found and fixed along the way: `sessionVersion` was never actually rotated anywhere, meaning a password change, MFA disable, or admin-forced deactivation didn't invalidate existing sessions/API tokens as documented; and the redirect card's "Change icon" menu action was silently falling through to the enable/disable toggle handler instead of opening the icon picker.
- Also folds in the config-drift attention-tile click-through fix from `v0.14.1` (never separately released): clicking the dashboard's "Configuration drift" item now goes to Administration \u2192 Gateway Defaults, not the generic Administration landing tab.
`v0.15.1` is a fix-list batch from live testing of `v0.15.0`, not new features:
- Native `<select>` dropdowns (Performance's Range picker and ~36 others app-wide) now render in the app's dark theme instead of the browser's light default \u2014 root cause was a missing `color-scheme` meta tag, already present on the other two themed pages but never added to the main app shell.
- The Live Health dashboard panel's badge/border now derive only from its own 6 displayed checks (gateway, HTTP, HTTPS, storage, streaming ports, upstreams) instead of the site-wide Needs Attention count, so an unrelated issue (a certificate warning, a site error) no longer turns the whole panel red.
- Hosted-site, proxy, and upstream-health attention items are now clickable, linking to the Hosted/Proxies list — previously only certificate and drift items had a click target.
- The Configuration drift attention tile now has its own inline "Resync now" button, instead of requiring a click-through to Gateway Defaults to find the same action.
- Fixed a false-positive drift bug: the drift check compared `JSON.stringify()` output directly, which is sensitive to key order — two semantically identical configs could register as "drifted" solely because Caddy serialized their keys differently. Replaced with an order-independent comparison. Drift detection now also logs a Gateway Events entry on first detection (not on every repeated check), so a future report of drift reappearing can be confirmed against a timestamp instead of guesswork.
- Toast notifications no longer render hidden/blurred behind an open dialog (missing `z-index`, and an open `<dialog>` renders above normal page content by default).
- The Top Paths popout now states it's showing the top 10, matching the existing server-side cap.
- The Runtime/System dashboard panel's top accent bar changed from a stray `--blue` token to `--green`, matching the default accent already used by every other dashboard tile.
`v0.15.2` fixed regressions introduced by `v0.15.1` and one deeper architectural bug:
- The dashboard attention tile's inline "Resync now" button (added in `v0.15.1`) silently did nothing — a script-generation guard meant to avoid double-adding its click handler matched on markup text that had already been introduced by the same change, so the handler was never actually attached. Fixed and verified by checking for the handler's functional code rather than just a string match.
- The Needs Attention dashboard chip and the attention-tile detail rows used different colors (amber vs. red) for the same condition; aligned to red.
- Configuration drift kept re-reporting immediately after a successful resync. The `v0.15.1` fix (order-independent JSON comparison) was necessary but not sufficient — the deeper issue was comparing a live running config against a freshly re-adapted Caddyfile, which will almost never match because Caddy fills in runtime defaults (automation policy, TLS management state) that never appear in a bare adapted config. Rewrote drift detection to compare two live-config snapshots against a captured baseline instead, recapturing that baseline after every successful sync.
- Removed the redundant "Resync now" callout from the Gateway Defaults page, superseded by the dashboard's inline button.
`v0.16.0` adds the System tab and closes out a round of fixes found during live use of `v0.15.x`:
- **New System tab** (Administration, first tab) — a read-only operations/diagnostics page: environment and integration status (Docker socket, `BACKUP_PASSWORD`), security status (default-credential and `ACME_EMAIL` detection), persistent gateway sync status with a Resync control, a scheduled-jobs table, per-folder storage usage, version/runtime info, and Reload/Restart controls. Restart is only enabled when the Docker socket is mounted and the container's own restart policy (checked via the Docker Engine API) is `always`, `unless-stopped`, or `on-failure`. The only interactive elements on the page are the Docker container-picker toggle (moved here from Gateway Defaults, which no longer carries integration/environment content) and the action buttons — everything else is status.
- Fixed a real correctness bug: `PATCH /api/settings` called `syncCaddy()` unconditionally before saving anything, for every settings change — including backups, certificate-health, and log-retention changes that have nothing to do with the Caddy config. An unrelated Caddy resync failure could silently discard and revert a just-saved change before it was ever persisted. `syncCaddy()` now only runs when a `defaultSite` change is part of the request; everything else saves unconditionally.
- The "Encrypt scheduled backups" toggle's helper text now positively confirms when `BACKUP_PASSWORD` is configured, instead of showing the same generic instructional copy regardless of whether it's set.
- `app.js`/`features.js`/`select-enhance.js` are now served with `Cache-Control: no-cache`, so browsers always revalidate instead of potentially serving a stale cached copy despite the version query string.
- Native `<select>` popups across the app are now replaced with a custom-drawn dark-themed listbox (the underlying native select is kept for form/value/event compatibility) — the `color-scheme` CSS hint shipped in `v0.15.1` turned out not to reliably theme native dropdown popups across real browsers/engines.
`v0.16.1` is a fix for a gap in `v0.16.0`'s own System tab: the Environment & Integrations section never actually rendered a `BACKUP_PASSWORD` status row (only the Docker socket status was there), despite the backend already exposing that data via `/api/config`. Fixed.
`v0.16.2` fixes two more issues found live-testing the System tab: the Docker container-selection sub-section was wrapped in its own `.dashboard-panel` styling while already nested inside the Integrations panel's own `.dashboard-panel`, producing a visibly doubled border/corner-radius/padding — de-chromed it into a plain sub-section instead. Also, the Version section's "Admin port" line showed the container's *internal* listening port, which isn't necessarily the port you actually reach the dashboard on through Docker's port mapping — replaced with the browser's own current address (`location.origin`), which is always correct regardless of how the port is mapped.
## Product direction
Site Gateway should remain simpler than a general-purpose proxy manager: one dashboard, clear health reporting, and guided setup instead of exposing server configuration. It can still cover most home-server publishing needs with an HTTP/HTTPS gateway alongside the existing static-file service.
Site Gateway stays simpler than a general-purpose proxy manager: one dashboard, clear health reporting, and guided setup instead of exposing raw server configuration. **Caddy** remains the managed gateway — Site Gateway stores a small route model and generates/validates Caddy configuration rather than reimplementing certificate and proxy behavior itself.
## Recommended gateway
## Shipped
Use **Caddy** as the managed gateway rather than rebuilding certificate and proxy behavior in Node or exposing raw Nginx configuration. The dashboard would store a small site model and generate/apply gateway configuration. Caddy provides automatic certificate issuance and renewal, redirects HTTP to HTTPS, supports reverse proxying and WebSockets, and has a configuration API suitable for safe validation before activation.
### Routing
The existing Node application remains responsible for authentication, the wizard, uploads, persistence, status, and audit events. Static sites can continue to use internal listeners while Caddy becomes the only public entry point on ports 80 and 443.
- **Hosted Sites** — upload a ZIP or `index.html`, publish on a domain and/or a direct LAN port, replace files without recreating the site.
- **Proxy Hosts** — forward a domain to any HTTP(S) target, with custom locations, headers, compression, upstream TLS, health checks, load-balancing across multiple upstreams, and expert Caddy snippets.
- **Redirect Hosts** — 301/302/307/308 responses with optional path preservation.
- **Streaming Hosts** — native TCP/UDP port forwarding with monitoring, for services that aren't HTTP (game servers, SSH, etc).
- Configurable themed welcome, 404, redirect, no-response, and custom-HTML fallback pages, with a live preview pane in Gateway Defaults.
## Proposed creation wizard
### Access and identity
### Step 1: What are you publishing?
- Local users with Administrator and Standard User roles, account lifecycle controls (disable/archive/restore).
- Groups, used to grant Access List membership without managing users one by one.
- Access Lists combining accounts, groups, and IP/CIDR network rules behind a themed sign-in page.
- Optional two-factor authentication (TOTP) with a self-service My Account view for enrolling and managing it, plus an administrator-side override (Administration → Users → “•••” → Disable 2FA) for a user who's locked out with no recovery codes left. Logged to the Audit log.
- First-time setup flow that finalizes the persistent administrator account from bootstrap credentials.
- REST API with admin-issued bearer tokens (full or read-only scope), bound to the issuing users session version so a password reset or deactivation revokes them automatically.
- Static website — upload a ZIP or `index.html`
- Existing application — proxy to an IP/hostname and port
- Redirect — send a domain or path to another URL
- Offline page — intentionally return a friendly maintenance/404 response
### Certificates and TLS
### Step 2: Address
- Automatic public HTTPS via Caddy, plus internal, HTTP-only, and uploaded custom-certificate modes.
- Certificate inventory: issuer, covered domains, validity, serial number, fingerprint, expiration, and last detected update.
- Dashboard alerts for certificates nearing expiration.
- Domain name(s)
- Optional path such as `/photos`
- Internal target and port for proxied applications
- Validation that ports and domains are not duplicated
### Observability
### Step 3: Security
- Live dashboard health for the gateway, HTTP, HTTPS, and storage, plus hosted/proxy/certificate counts and throughput.
- System panel: uptime, memory, persistent-data size, disk space, installed app/Caddy versions, public IP.
- Performance view with request throughput, response times, per-route breakdowns, p95 latency, bandwidth, and unique-visitor columns, a 4xx/5xx-colored error breakdown, a top-10-paths-per-host popout, and a slowest-requests panel — the host filter applies to the throughput table as well as the trend chart, and average response times display in seconds once they pass 1000ms.
- A read-only "View Caddy config" popout on Hosted Sites, Proxy Hosts, and Redirect Hosts, showing the exact Caddyfile block generated for that route, built from the same code path that generates the real deployed config so it can never drift from whats shown.
- Dashboard tile colors are unified around a shared green baseline that reacts to warning/danger states, matching the existing Needs Attention tiles behavior.
- Configuration drift detection compares Caddys live configuration against the saved routes every 10 minutes, flags a Needs Attention item with a one-click inline "Resync now" action, and logs a Gateway Events entry the first time drift is detected.
- Rotating access and activity logs.
- Update-available banner when a newer image is deployed.
- A redacted support-report export exists (version, config health, certificate readiness, upstream checks, recent events) but its UI entry point is currently hidden pending a readability rewrite of the report's output format.
- Automatic public TLS certificate
- HTTP only for trusted LAN use
- Upload an existing certificate
- Force HTTPS
- HSTS, shown as an advanced option with a clear lockout warning
### Data and operations
### Step 4: Access
- Built-in SQLite persistence at `/data/database/site-gateway.sqlite` — no external database container.
- Configuration and Complete backups, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted; pre-restore safety backups and configuration validation before activation.
- PUID/PGID-aware startup for Unraid and ZimaOS-style permission models.
- A durable, database-backed history of every backup, restore, and deletion attempt, shown as a human-readable timeline.
- An opt-in Docker container picker (gated on the Docker socket being mounted and readable) for choosing Proxy/Streaming targets from the hosts running containers instead of typing them by hand.
- Public
- Basic username/password
- IP allow/deny list
- Optional security headers preset
- A System tab (Administration) surfacing environment/integration status, security status, storage usage, scheduled jobs, gateway sync status, and reload/restart controls in one read-only operations page.
### Step 5: Review and publish
### Brand and docs
- Plain-language configuration summary
- DNS and router checks
- Configuration validation before activation
- Immediate rollback if gateway reload fails
- Current icon and wordmark (v0.11.99) used consistently across the login screen, sidebar, themed default pages, and this README.
- A sitewide design-token system (colors, spacing, radius, and type scale defined once and reused everywhere) underpins the interface, so new UI stays visually consistent by default.
- Integrated, searchable in-app documentation covering every configurable field, including 2FA (self-service and the administrator override) and the update-notification banner.
- Toast notifications are color-coded — error toasts render distinctly from success/neutral ones, using the same token-driven theming as the rest of the interface.
- Companion marketing site with an installation guide covering Docker Compose, plain `docker run`, and Unraid.
## Delivery phases
## What's next
### Dashboard foundation (implemented in v0.4.0-alpha.1)
Roughly in priority order:
- Default overview with hosted-site, proxy-host, TLS-domain, and attention totals
- Gateway, hosted-site, and proxy-host health indicators
- Safe runtime reporting for uptime, memory, persistent-data size, disk space, and installed versions
- Recent configuration activity for the current container session
- Responsive navigation for desktop and mobile
- Infrastructure-focused live health for Caddy, HTTP, HTTPS automation, and persistent storage (refined in v0.4.0-alpha.2)
- Confirmed port health, clearer storage reporting, local service icons, and resilient dashboard controls (v0.4.0-alpha.3)
- Certificate inventory and expiration alerts, proxy upstream monitoring, and filtered rotating access logs (v0.5.0-alpha.1)
- Corrected certificate wording and standardized dashboard, card, and log-control spacing (v0.5.0-alpha.2)
- Persistent local users, Administrator and Standard User roles, account lifecycle controls, and role-aware sessions (v0.6.0-alpha.1)
- Redirect Hosts, Access Lists with themed authentication, advanced Proxy Host controls, custom certificates, configurable fallback pages, integrated documentation, Administration, and backup/restore (v0.7.0-alpha.1)
- Built-in SQLite persistence, Local Gateway instance scoping, JSON migration safeguards, unified certificate storage, and database-aware backups (v0.8.0-alpha.1)
- First-install sign-in guidance and required one-time administrator account finalization (v0.8.0-alpha.2)
- Certificate details, configurable expiration thresholds, guided domain diagnostics, on-demand health checks, redacted support reports, and authentication cleanup (v0.9.0-alpha.1)
### Completed in v0.9.0-alpha.1 — visibility and certificate health
This should be the next implementation target. It adds the reporting people rely on in NGINX Proxy Manager without expanding the creation workflow yet.
- Certificate inventory derived from Caddy's managed certificate storage
- Domain, issuer, valid-from, expiration date, and days remaining
- Clear **Healthy**, **Renewing soon**, **Expired**, and **Needs attention** states
- Dashboard counts for certificates expiring within 30 and 7 days
- Last successful renewal and last certificate error when available
- Per-host upstream reachability checks with response time and last-check timestamp
- Recent gateway errors and a concise per-host access-log view
- Diagnostics that distinguish DNS, inbound port, certificate, and upstream failures
- Never display private keys, account credentials, or raw sensitive configuration
### v0.10.0-alpha.1 — gateway completeness (in progress)
- Basic Caddy upstream pools for load balancing across multiple targets
- Universal Dashboard Icons search, custom upload, HTTPS URL, and two-letter fallback
- Access List assignment visibility on hosts
- Complete themed Default Site responses
- Clear certificate renewal-event wording and per-host operational reporting
### Phase 1 — Domains and automatic HTTPS (gateway alpha implemented)
- Publish ports 80 and 443
- Domain assignment for static sites
- Automatic certificate issue and renewal
- Force-HTTPS option
- Certificate status and expiration reporting (next alpha milestone)
- Guided DNS/router readiness checks (next alpha milestone)
### Phase 2 — Reverse proxy and redirects (implemented)
- Proxy to other containers, LAN devices, or URLs
- WebSocket support
- Redirect hosts and offline/404 hosts
- Standard security-header presets
- Optional HSTS after HTTPS is verified
- Per-host access logs and simple health checks
### Phase 3 — Access and advanced certificates (partially implemented)
- Themed-login access policies reusable across proxy hosts (implemented)
- IP/CIDR allow lists (implemented)
- Custom certificate upload (implemented)
- Wildcard certificates through selected DNS providers
- Backup/export and restore, including encryption and scheduling (implemented)
- Configuration validation and automatic restore rollback (implemented); browsable history remains planned
### Phase 4 — Multi-user and specialist features
- Multiple administrators and roles
- Audit log
- TCP/UDP stream forwarding
- Rate limiting
- Carefully constrained advanced configuration snippets
- **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.
- **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.
- **Rate limiting** and other specialist gateway controls.
## Important constraints
- Public automatic certificates require working public DNS and inbound access to ports 80/443 unless a DNS challenge is configured.
- HSTS should never be enabled by default; a bad configuration can make a domain inaccessible until the browser policy expires.
- Wildcard/DNS certificates require storing DNS-provider credentials and therefore need encrypted secret storage.
- Wildcard/DNS certificates require storing DNS-provider credentials and therefore need encrypted secret storage before they can ship.
- Ports 80 and 443 must not already be owned by another reverse proxy on the same host.
- Arbitrary Nginx/Caddy snippets substantially increase support and security risk and should remain an expert-only feature.
- A Docker-socket-based container picker is opt-in only — socket access is root-equivalent on the host and should never be a default requirement.
- Arbitrary Caddy snippets substantially increase support and security risk and stay an expert-only, size-limited, validated feature.
## Scope recommendation
`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.
Prioritize reporting before adding more creation options: certificate health, renewal visibility, upstream checks, and useful logs make the existing gateway trustworthy. Follow that with redirect hosts and reusable access lists. Custom certificates, DNS challenges, streams, multi-user roles, and raw snippets should remain later advanced work because they add credential-storage, validation, and support complexity.
`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.
## NGINX Proxy Manager alignment
`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.
| Capability | Site Gateway direction | Priority |
| --- | --- | --- |
| Proxy hosts, WebSockets, automatic HTTPS | Implemented through guided Caddy configuration | Current |
| Certificate expiration and renewal reporting | First-class certificate health page and dashboard alerts | Next |
| Access logs and traffic reporting | Recent requests, status distribution, bytes, and errors per host; avoid promising full analytics | Next |
| Upstream health | Reachability, response time, and failure reason per proxy target | Next |
| Redirect hosts and maintenance responses | Implemented as Redirect Hosts and configurable Default Site behaviors | Current |
| Access lists and authentication | Reusable policies with network rules and a themed sign-in flow | Current |
| DNS and reachability diagnostics | Guided checks for resolution, public IP, ports 80/443, and certificate eligibility | Near term |
| Custom certificates | Validated matching certificate/key upload and complete-backup support | Current |
| Wildcard certificates | Selected DNS-provider integrations with encrypted API credentials | Later |
| Advanced proxy options | Custom locations, headers, compression, upstream TLS, health expectations, and validated snippets | Current |
| TCP/UDP streams | Separate advanced area with explicit port-conflict checks | Later |
| Backup and restore | Configuration/complete archives, browser download/import, schedules, retention, encryption, and rollback | Current |
`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.
+16 -1
View File
@@ -1,3 +1,8 @@
# Pulls the published multi-arch image instead of building from source.
# Copy .env.example to .env, set ADMIN_PASSWORD and SESSION_SECRET, then:
# docker compose -f compose.release.yaml pull
# docker compose -f compose.release.yaml up -d
services:
site-gateway:
image: ${SITE_GATEWAY_IMAGE:-ghcr.io/mfwadejr/site-gateway2:latest}
@@ -18,10 +23,20 @@ services:
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${HTTPS_PORT:-443}:443/udp"
- "${HTTPS_PORT:-443}:443/udp" # HTTP/3 (QUIC) — forward UDP 443 on your router/firewall too
- "8080:8080"
- "9000-9099:9000-9099"
# Streaming Hosts (optional): add one line per TCP/UDP port you forward
# from the dashboard, matching the target port you'll enter there.
# - "25565:25565"
# - "25565:25565/udp"
volumes:
- ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data
# Optional: enables "Pick from running containers" for Proxy and Streaming
# host targets (Administration > Gateway defaults > Docker container selection).
# Read-only, but be deliberate: access to the Docker socket is effectively root
# on the host -- anything that can talk to it can start privileged containers and
# mount the host filesystem. Leave this commented out unless you want the feature.
# - /var/run/docker.sock:/var/run/docker.sock:ro
labels:
com.centurylinklabs.watchtower.enable: "true"
+40 -3
View File
@@ -1,27 +1,64 @@
services:
site-gateway:
build: .
container_name: site-gateway
restart: unless-stopped
environment:
# Bootstrap dashboard credentials. Required — change the password before
# starting the container. These become the initial Administrator account;
# first-time setup will ask you to confirm or replace them.
ADMIN_USERNAME: admin
ADMIN_PASSWORD: change-this-password
# Optional: set a long random SESSION_SECRET to keep sessions valid across
# container rebuilds. If omitted, one is derived from ADMIN_USERNAME/ADMIN_PASSWORD.
# Optional: set a long random string to keep login sessions valid across
# container rebuilds. If omitted, one is derived from the admin
# username/password above. Rotating this signs everyone out.
# SESSION_SECRET: ""
# Dashboard port inside the container. Change the left side of the
# matching entry under `ports:` below if you remap it.
ADMIN_PORT: 8080
# Direct-LAN port range Hosted Sites can bind to. Keep this in sync with
# the "9000-9099:9000-9099" line under `ports:` below — Docker can't add
# a host port to an already-running container, so widen both together
# before starting the container if you need a different range.
SITE_PORT_MIN: 9000
SITE_PORT_MAX: 9099
# Where persistent state lives inside the container. Leave this as
# /data and change the host side of the volume mount instead.
DATA_DIR: /data
# Optional: encryption password for scheduled Complete/Configuration
# backups. Leave empty to store backups unencrypted (not recommended,
# since Complete backups include certificate private keys).
BACKUP_PASSWORD: ""
# User/group the container writes persistent files as. Defaults suit a
# typical Linux host; Unraid commonly uses PUID=99, PGID=100.
PUID: 1000
PGID: 1000
# Optional: certificate account email, passed to Caddy's ACME client.
ACME_EMAIL: ""
ports:
- "80:80"
- "443:443"
- "443:443/udp"
- "443:443/udp" # HTTP/3 (QUIC) — forward UDP 443 on your router/firewall too
- "8080:8080"
- "9000-9099:9000-9099"
# Streaming Hosts (optional): add one line per TCP/UDP port you forward
# from the dashboard, matching the target port you'll enter there.
# Example for a Minecraft server:
# - "25565:25565"
# - "25565:25565/udp"
volumes:
- ./data:/data
# Optional: enables "Pick from running containers" for Proxy and Streaming
# host targets (Administration > Gateway defaults > Docker container selection).
# Read-only, but be deliberate: access to the Docker socket is effectively root
# on the host -- anything that can talk to it can start privileged containers and
# mount the host filesystem. Leave this commented out unless you want the feature.
# - /var/run/docker.sock:/var/run/docker.sock:ro
+83
View File
@@ -0,0 +1,83 @@
# ZimaOS App Store / custom-install compose file for Site Gateway.
#
# Import this file directly (ZimaOS → Docker → Install a Customized App →
# paste/select this file), or use it as the source compose for a ZimaOS
# App Store submission — the `x-casaos` block below follows ZimaOS's v2
# app-store schema (see docs.zimaspace.com/docs/developer/app-store-compose-x-casaos).
#
# After install, open the ZimaOS app's settings and change ADMIN_PASSWORD
# and SESSION_SECRET before exposing this to anything beyond your LAN.
name: site-gateway
services:
site-gateway:
image: ghcr.io/mfwadejr/site-gateway2:latest
container_name: site-gateway
restart: unless-stopped
environment:
ADMIN_USERNAME: admin
ADMIN_PASSWORD: change-this-password
SESSION_SECRET: change-this-too-at-least-32-characters
ADMIN_PORT: 8080
SITE_PORT_MIN: 9000
SITE_PORT_MAX: 9099
DATA_DIR: /data
BACKUP_PASSWORD: ""
# ZimaOS commonly runs its data volumes as 1000:1000; adjust if your
# instance differs.
PUID: 1000
PGID: 1000
ACME_EMAIL: ""
ports:
- target: 8080
published: "8080"
protocol: tcp
- target: 80
published: "80"
protocol: tcp
- target: 443
published: "443"
protocol: tcp
- target: 443
published: "443"
protocol: udp
- target: 9000
published: "9000-9099"
protocol: tcp
# Streaming Hosts (optional): add one entry per TCP/UDP port you forward
# from the dashboard, matching the target port you'll enter there.
# - target: 25565
# published: "25565"
# protocol: tcp
volumes:
- type: bind
source: /DATA/AppData/site-gateway
target: /data
x-casaos:
id: dev.mfwadejr.sitegateway
main: site-gateway
index: /
port_map: "8080"
scheme: http
icon: https://raw.githubusercontent.com/mfwadejr/site-gateway2/main/src/public/site-gateway-icon-approved.png
title:
en_US: Site Gateway
tagline:
en_US: Host. Proxy. Secure. A calm dashboard for sites, proxies, and TLS.
description:
en_US: >-
A friendly, self-hosted gateway for homelabs and small teams. Publish
static sites, reverse-proxy your apps, forward raw TCP/UDP streams, and
manage TLS and access from one dashboard — powered by Caddy.
category: Networking
author: mfwadejr
developer: mfwadejr
architectures: ["amd64", "arm64"]
version: "0.11.101"
update_at: "2026-09-16"
website: https://github.com/mfwadejr/site-gateway2
repo: https://github.com/mfwadejr/site-gateway2
support: https://github.com/mfwadejr/site-gateway2/issues
+43 -1
View File
@@ -29,9 +29,51 @@ fi
export XDG_DATA_HOME="${DATA_DIR:-/data}/certificates/managed"
export XDG_CONFIG_HOME="${DATA_DIR:-/data}/caddy/config"
# --- Docker socket group access -------------------------------------------------------------
# A bind-mounted /var/run/docker.sock is typically owned root:docker on the host with mode
# 0660 -- readable only by root or members of that group. The app drops straight to an
# unprivileged PUID:PGID with no supplementary groups, so even a correctly mounted socket looks
# "not detected" to it. The Docker group's GID varies host to host (Unraid, Debian, Synology,
# etc. all differ), so rather than hardcode one, read it directly off the mounted socket while
# still root, make sure a local group with that GID exists and the app user is a member of it,
# then hand su-exec a username instead of a bare uid:gid so it picks up supplementary groups via
# initgroups() -- the uid:gid form only ever sets the one primary group. Every step here is
# best-effort: if anything fails, app_exec_target stays the original "$app_uid:$app_gid" and the
# app starts exactly as it always has, just without Docker integration -- same as an unmounted
# socket, never worse.
app_exec_target="$app_uid:$app_gid"
docker_socket="/var/run/docker.sock"
if [ -S "$docker_socket" ]; then
docker_gid="$(stat -c '%g' "$docker_socket" 2>/dev/null || true)"
if [ -n "$docker_gid" ] && [ "$docker_gid" != "$app_gid" ]; then
docker_group_name="$(getent group "$docker_gid" 2>/dev/null | cut -d: -f1 || true)"
if [ -z "$docker_group_name" ]; then
addgroup -g "$docker_gid" sgdockersock 2>/dev/null || true
docker_group_name="$(getent group "$docker_gid" 2>/dev/null | cut -d: -f1 || true)"
fi
if [ -n "$docker_group_name" ]; then
app_group_name="$(getent group "$app_gid" 2>/dev/null | cut -d: -f1 || true)"
if [ -z "$app_group_name" ]; then
addgroup -g "$app_gid" sgapp 2>/dev/null || true
app_group_name="$(getent group "$app_gid" 2>/dev/null | cut -d: -f1 || true)"
fi
if [ -n "$app_group_name" ] && ! getent passwd "$app_uid" >/dev/null 2>&1; then
adduser -D -H -u "$app_uid" -G "$app_group_name" sgapp 2>/dev/null || true
fi
app_user_name="$(getent passwd "$app_uid" 2>/dev/null | cut -d: -f1 || true)"
if [ -n "$app_user_name" ]; then
addgroup "$app_user_name" "$docker_group_name" 2>/dev/null || true
if id -nG "$app_user_name" 2>/dev/null | grep -qw "$docker_group_name"; then
app_exec_target="$app_user_name"
fi
fi
fi
fi
fi
su-exec "$app_uid:$app_gid" caddy run --config "$caddyfile" --adapter caddyfile &
caddy_pid=$!
su-exec "$app_uid:$app_gid" "$@" &
su-exec "$app_exec_target" "$@" &
app_pid=$!
shutdown() {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.11.92",
"version": "0.16.50",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+476 -87
View File
@@ -1,22 +1,25 @@
// ============================================================================
// app.js -- core client application: state, API helper, dashboard,
// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/
// edit/icon/user/account/MFA), and every event listener for those areas. The
// remaining views (Streaming, Redirects, Access Lists, Administration panels)
// live in features.js and are invoked from here via window.renderExtendedViews.
// ============================================================================
// --- Shared DOM shortcut and app state ----------------------------------------
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 };
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
// forms (features.js owns the Access List data, this file owns these forms).
document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain ");
if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this hosted site and all of its domains.</small>'; anchor.before(label); } }
if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select id="settings-access-list" name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this route and all of its domains.</small>'; anchor.after(label); } }
const proxyAccessLabel = document.querySelector("#proxy-form [name=accessListId]")?.closest("label"); const proxyTlsLabel = document.querySelector("#proxy-form [name=tls]")?.closest("label"); if (proxyAccessLabel && proxyTlsLabel) proxyTlsLabel.before(proxyAccessLabel);
const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel);
document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove();
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
function applyTheme(preference) {
const effective = preference === "system" ? (systemTheme.matches ? "dark" : "light") : preference;
document.documentElement.dataset.theme = effective;
document.querySelector('meta[name="theme-color"]').content = effective === "dark" ? "#08101d" : "#f3f6fa";
}
const savedTheme = localStorage.getItem("webserver-theme") || "system";
$("#theme-select").value = savedTheme; applyTheme(savedTheme);
$("#theme-select").addEventListener("change", event => { localStorage.setItem("webserver-theme", event.target.value); applyTheme(event.target.value); });
systemTheme.addEventListener("change", () => { if ($("#theme-select").value === "system") applyTheme("system"); });
// --- API helper ------------------------------------------------------------------
async function api(url, options = {}) {
const response = await fetch(url, options);
@@ -24,11 +27,14 @@ async function api(url, options = {}) {
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); }
return response.status === 204 ? null : response.json();
}
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); }
// --- Login/dashboard shell, toast, and small formatting helpers ------------------
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 toast(message) { const el = $("#toast"); el.textContent = message; 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 publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; }
function formatBytes(value) {
if (!Number.isFinite(value)) return "Unavailable";
if (value < 1024) return `${value} B`;
@@ -36,15 +42,18 @@ function formatBytes(value) {
for (let index = 1; size >= 1024 && index < units.length; index++) { size /= 1024; unit = units[index]; }
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${unit}`;
}
function formatDuration(seconds) {
if (!Number.isFinite(seconds)) return "Unavailable";
const days = Math.floor(seconds / 86400), hours = Math.floor(seconds % 86400 / 3600), minutes = Math.floor(seconds % 3600 / 60);
if (days) return `${days}d ${hours}h`; if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`;
}
function formatTime(value) {
if (!value) return "Just now";
const date = new Date(value); return Number.isNaN(date.getTime()) ? "Recently" : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
}
function formatRelativeTime(value) {
if (!value) return "Just now";
const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently";
@@ -59,19 +68,25 @@ function formatRelativeTime(value) {
if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;
return formatTime(value);
}
function certificateStatusLabel(status) { return ({ healthy:"Healthy", warning:"Renewal due soon", critical:"Renewal required urgently", expired:"Expired", pending:"Awaiting Caddy / ACME certificate", mismatch:"Certificate does not cover this domain" }[status] || String(status || "Unknown")).replaceAll("-", " "); }
function parseHeaderLines(value) { return String(value || "").split("\n").map(line => { const index = line.indexOf(":"); return index > 0 ? { name:line.slice(0,index).trim(), value:line.slice(index+1).trim() } : null; }).filter(Boolean); }
function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; return Boolean(form.querySelector(`${scope} [name="healthEnabled"]`)?.checked); }
// event.submitter is null on implicit form submission (e.g. pressing Enter in a field instead of
// clicking the button), which previously crashed every save handler below on `button.disabled = true`
// and silently dropped the whole save. Fall back to the form's actual submit button.
function resolveSubmitter(event) { return event.submitter || event.target.querySelector('button:not([type="button"])'); }
function scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; }
// #settings-form reuses field names (healthEnabled, healthPath, accessListId, compression, etc.) between the
// hidden site-scoped (#settings-hosted-advanced) and proxy-scoped (#settings-advanced) sections. form.elements.NAME
// resolves to a RadioNodeList when a name is duplicated, and assigning .value/.checked to a RadioNodeList of
// non-radio inputs silently does nothing — so every one of these fields must be read/written through its scope.
function setScoped(form, scope, name, value) { const el = form.querySelector(`${scope} [name="${name}"]`); if (!el) return; if (el.type === "checkbox") el.checked = Boolean(value); else el.value = value; }
// advancedFormBody -- reads the scoped or unscoped "advanced options" fields off
// a create/edit form and merges them into the outgoing request body.
function advancedFormBody(form, body, scoped) {
// scoped = { scope, formEl } — pass this when `form` came from a shared form (like #settings-form) where
// field names collide with another section, so every ambiguous field is read from its own scope instead of
@@ -79,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 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.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.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);
@@ -88,6 +103,8 @@ function advancedFormBody(form, body, scoped) {
delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText;
return body;
}
// --- Proxy/Hosted settings dialog: submit handler ---------------------------------
// Single capture-path for monitoring settings: unchecked checkboxes must be sent as false.
document.addEventListener("submit", async event => {
if (event.target?.id !== "settings-form" || !state.editing) return;
@@ -96,7 +113,7 @@ document.addEventListener("submit", async event => {
const certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile");
let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile;
if (state.editing.kind === "proxy") body = advancedFormBody(form, body, { scope: "#settings-advanced", formEl: event.target });
else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; }
else { const scope = "#settings-hosted-advanced"; body = { name: body.name, domain: body.domain, domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; }
const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size;
if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) {
const existing = state.proxies.find(item => item.id === state.editing.id);
@@ -106,12 +123,14 @@ document.addEventListener("submit", async event => {
try {
await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); }
$("#settings-dialog").close(); await refresh(); toast("Gateway settings applied.");
$("#settings-dialog").close(); await refreshCurrentView(); toast("Gateway settings applied.");
}
catch (error) { $("#settings-error").textContent = error.message; }
finally { button.disabled = false; }
}, true);
// --- Dashboard rendering ----------------------------------------------------------
function healthCopy(group, label) {
if (!group.total) return "Nothing configured";
if (group.errors) return `${group.errors} ${group.errors === 1 ? label.replace(/s$/, "") : label} need attention`;
@@ -126,8 +145,35 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") {
return service.status === "ready" ? ready : error;
}
function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
// Dashboard tiles share one baseline accent (green) and switch to the existing
// --warning / --danger tokens when the thing they count is actually in trouble --
// the same mechanism the "Needs attention" chip already used.
const TILE_ACCENT_CLASSES = ["accent-green", "accent-blue", "accent-amber", "accent-purple", "accent-warning", "accent-danger"];
function setTileAccent(valueId, level) {
const tile = $(valueId)?.closest(".metric-card, .metric-chip");
if (!tile) return;
tile.classList.remove(...TILE_ACCENT_CLASSES);
tile.classList.add(level === "danger" ? "accent-danger" : level === "warning" ? "accent-warning" : "accent-green");
}
function applyTileAccents(data) {
const group = value => !value?.total || !value.errors ? "green" : value.errors >= value.total ? "danger" : "warning";
setTileAccent("#dash-hosted-total", group(data.hosted));
const upstreams = data.upstreams || { total: 0, unhealthy: 0 };
const proxyLevel = group(data.proxies);
setTileAccent("#dash-proxy-total", proxyLevel !== "green" ? proxyLevel : upstreams.unhealthy > 0 ? (upstreams.unhealthy >= upstreams.total ? "danger" : "warning") : "green");
const certificates = data.certificates || {};
const certificateLevel = (certificates.expired || 0) + (certificates.mismatch || 0) > 0 ? "danger" : (certificates.warning || 0) + (certificates.critical || 0) > 0 ? "warning" : "green";
setTileAccent("#dash-tls-total", certificateLevel);
// Redirect hosts have no runtime failure state of their own, so they stay on the baseline.
setTileAccent("#dash-redirect-total", "green");
const streaming = data.streamingPorts || { total: 0, listening: 0 };
setTileAccent("#dash-stream-total", !streaming.total || streaming.listening === streaming.total ? "green" : streaming.listening === 0 ? "danger" : "warning");
// Throughput is a rate, not a health signal: there is no "bad" value to react to.
setTileAccent("#dash-throughput-total", "green");
}
function renderDashboard() {
const data = state.dashboard; if (!data) return;
if (data.system) renderDashboardJobsSafe(data.system);
@@ -141,11 +187,13 @@ function renderDashboard() {
$("#dash-stream-total").textContent = state.streams?.length || 0;
$("#dash-attention-total").textContent = data.attention.length;
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues";
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
applyTileAccents(data);
$("#dash-attention-chip").classList.toggle("accent-danger", data.attention.length > 0);
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
$("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓";
$("#dash-throughput-total").textContent = data.throughput?.liveRequests ?? 0;
const hasErrors = data.attention.length > 0, isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
const panelStreaming = data.streamingPorts || { total: 0, listening: 0 }, panelUpstreams = data.upstreams || { total: 0, healthy: 0, unhealthy: 0 };
const hasErrors = data.gateway.status === "error" || data.services.http.status === "error" || data.services.https.status === "error" || !data.services.storage.healthy || (panelStreaming.total > 0 && panelStreaming.listening !== panelStreaming.total) || (panelUpstreams.total > 0 && panelUpstreams.unhealthy > 0), isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
const overall = $("#overall-health");
overall.className = `health-badge ${hasErrors ? "error" : isChecking || hasNothingRunning ? "warning" : "healthy"}`;
overall.textContent = hasErrors ? "Needs attention" : isChecking ? "Checking" : hasNothingRunning ? "Idle" : "Healthy";
@@ -165,24 +213,22 @@ function renderDashboard() {
$("#upstream-health-dot").className = `status-dot ${!upstreams.total ? "inactive" : upstreams.unhealthy > 0 ? "error" : "running"}`;
$("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`;
$("#health-checked").innerHTML = `<span class="live-dot" id="health-live-dot"></span>Last checked ${formatTime(data.checkedAt)}`;
updateDashboardUptime(data.system.uptimeSeconds);
$("#system-memory").textContent = formatBytes(data.system.memoryBytes);
$("#system-data").textContent = formatBytes(data.system.dataBytes);
$("#system-disk").textContent = formatBytes(data.system.diskFreeBytes);
$("#system-disk").title = `${formatBytes(data.system.diskFreeBytes)} available of ${formatBytes(data.system.diskTotalBytes)} on the /data volume`;
$("#system-app-version").textContent = `v${data.system.appVersion}`;
$("#system-caddy-version").textContent = data.system.caddyVersion;
$("#system-database").textContent = `${data.system.databaseEngine} · ${data.system.databaseStatus}`;
$("#system-database-detail").textContent = `${formatBytes(data.system.databaseBytes)} configuration database`;
$("#system-public-ip").textContent = data.system.publicIp || (data.system.publicIpError ? "Unavailable" : "Checking…");
$("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked";
// Memory/Data/Storage/Version/Database/Public IP moved to the Administration > System tab's
// Version panel -- the Dashboard's own Runtime/System panel is now the shared hero component
// (see renderHeroPanel/refreshDashboardHero), which reads real container-scoped CPU/memory/
// swap/disk/network from /api/system/health instead of this endpoint's coarser numbers.
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
$("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0);
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.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>';
$("#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>${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"}>`
).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>';
}
setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000);
// --- Card rendering helpers (icons, permissions) -----------------------------------
function initials(name) {
const words = String(name || "").trim().split(/\s+/).map(word => word.replace(/[^a-z0-9]/gi, "")).filter(Boolean);
if (!words.length) return "??";
@@ -193,22 +239,26 @@ document.addEventListener('error', event => { const image = event.target; if (!(
function canManage() { return ["administrator", "standard"].includes(state.user?.role); }
function canAdmin() { return state.user?.role === "administrator"; }
// --- Hosted Sites & Proxy Hosts: card templates ------------------------------------
function hostedCard(site) {
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 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="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>` : "";
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>`;
}
function proxyCard(proxy) {
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 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="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 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>`;
}
// --- Certificates view --------------------------------------------------------------
function renderCertificates() {
const data = state.certificates; if (!data) return;
$("#certificate-count").textContent = data.summary.total;
@@ -219,6 +269,8 @@ function renderCertificates() {
renderReadiness();
}
// --- Domain readiness (used inside the Certificates view) ---------------------------
function renderReadiness() {
const routes = state.readiness?.routes || [];
$("#readiness-list").innerHTML = routes.length ? routes.map(item => {
@@ -232,6 +284,8 @@ function renderReadiness() {
}).join("") : '<p class="quiet-state">No configured domains to check.</p>';
}
// --- Logs view -------------------------------------------------------------------------
function renderLogs() {
const data = state.logs; if (!data) return;
const selected = $("#log-host").value; $("#log-host").innerHTML = '<option value="">All domains</option>' + data.hosts.map(host => `<option value="${escapeHtml(host)}">${escapeHtml(host)}</option>`).join(""); $("#log-host").value = selected;
@@ -245,6 +299,23 @@ function renderLogs() {
$("#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>';
}
// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline),
// and the per-domain throughput table -----------------------------------------------
// Clock-boundary label spacing per selected range (hours -> minutes between labels).
const PERFORMANCE_LABEL_MINUTES = { 1: 15, 3: 30, 6: 60, 12: 120, 24: 180, 72: 720, 168: 1440 };
function formatChartTime(value, intervalMinutes) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
if (intervalMinutes >= 1440) return date.toLocaleDateString([], { month: "short", day: "numeric" });
if (intervalMinutes >= 720) return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric" });
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
function formatLatency(ms) { return ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; }
// Geometry shared by the chart renderer and the hover tooltip.
const PERFORMANCE_CHART = { left: 34, right: 8, top: 10, bottom: 20, width: 600, height: 140 };
function renderPerformance() {
const data = state.performance; if (!data) return;
const selected = $("#performance-host").value;
@@ -255,8 +326,9 @@ function renderPerformance() {
const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
const points = data.trend || [];
state.performancePoints = points;
const max = Math.max(1, ...points.map(point => point.count));
const left = 34, right = 8, top = 10, bottom = 20, width = 600, height = 140;
const { left, right, top, bottom, width, height } = PERFORMANCE_CHART;
const plotWidth = width - left - right, plotHeight = height - top - bottom;
const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth);
const yAt = count => top + plotHeight - (count / max) * plotHeight;
@@ -265,16 +337,34 @@ function renderPerformance() {
const y = (top + plotHeight * (1 - fraction)).toFixed(1);
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--line)" stroke-width="1" />`;
}).join("");
const leftPct = (left / width) * 100, topPct = 0, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
const leftPct = (left / width) * 100, plotWidthPct = (plotWidth / width) * 100, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
const axisLabels = gridFractions.map(fraction => {
const value = Math.round(max * fraction);
const yPct = topInsetPct + plotHeightPct * (1 - fraction);
return `<span class="axis-label" style="left:0;width:${(leftPct - 2).toFixed(2)}%;top:${yPct.toFixed(2)}%;text-align:right">${value}</span>`;
}).join("");
const firstPoint = points[0], lastPoint = points[points.length - 1];
const timeLabels = points.length ? `<span class="time-label" style="left:${leftPct.toFixed(2)}%">${escapeHtml(formatTime(firstPoint.at))}</span><span class="time-label time-label-end" style="left:${(100 - (right / width) * 100).toFixed(2)}%">${escapeHtml(formatTime(lastPoint.at))}</span>` : "";
// Time axis: labels land on real clock boundaries scaled to the selected range, and the
// true first and last sample are always labelled so the window's edges stay readable.
const hours = Number($("#performance-range").value) || 6;
const intervalMinutes = PERFORMANCE_LABEL_MINUTES[hours] || 60;
const intervalMs = intervalMinutes * 60000;
let timeLabels = "";
if (points.length) {
const candidates = new Set([0, points.length - 1]);
const aligned = [];
points.forEach((point, index) => { const time = new Date(point.at).getTime(); if (!Number.isNaN(time) && time % intervalMs === 0) aligned.push(index); });
const stride = Math.max(1, Math.ceil(aligned.length / 8));
aligned.forEach((index, position) => { if (position % stride === 0) candidates.add(index); });
const ordered = [...candidates].sort((a, b) => a - b);
timeLabels = ordered.map(index => {
const leftEdge = leftPct + (points.length > 1 ? (index / (points.length - 1)) * plotWidthPct : plotWidthPct);
const alignment = index === 0 ? "" : index === points.length - 1 ? " time-label-end" : " time-label-mid";
return `<span class="time-label${alignment}" style="left:${leftEdge.toFixed(2)}%">${escapeHtml(formatChartTime(points[index].at, intervalMinutes))}</span>`;
}).join("");
}
$("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : "";
const coords = points.map((point, index) => [xAt(index), yAt(point.count)]);
state.performanceCoords = coords;
const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => {
if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`;
const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1];
@@ -288,14 +378,62 @@ function renderPerformance() {
const baseline = (top + plotHeight).toFixed(1);
const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : "";
$("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`);
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />` : "";
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse id="performance-hover-dot" class="hidden" cx="0" cy="0" rx="3" ry="3" fill="var(--green)" stroke="var(--panel)" stroke-width="1.5" />` : "";
if (!points.length) $("#performance-sparkline-labels").innerHTML = '<span class="axis-label" style="left:0;width:100%;top:45%;text-align:center">No request data for this window yet.</span>';
const routes = data.routes || [];
const countCell = (count, errors, breakdown) => { const title = breakdown?.length ? ` title="${escapeHtml(breakdown.map(item => `${item.status}: ${item.count.toLocaleString()}`).join(" · "))}"` : ""; return `${count.toLocaleString()}${errors ? ` <span class="count-divider">·</span> <span class="http-status bad"${title}>${errors.toLocaleString()}</span>` : ""}`; };
$("#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)}</td><td>${route.dayAvgMs == null ? "—" : `${route.dayAvgMs} ms`}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
hidePerformanceTooltip();
const routes = (data.routes || []).filter(route => !selected || route.host === selected);
state.performanceErrorBreakdowns = {};
state.performanceTopPaths = {};
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>`; };
// 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" });
}
// --- Performance: hover tooltip on the request-trend chart -------------------------------
function hidePerformanceTooltip() {
$("#performance-tooltip")?.classList.add("hidden");
document.querySelector("#performance-hover-dot")?.classList.add("hidden");
}
function showPerformanceTooltip(event) {
const svg = $("#performance-sparkline"), tooltip = $("#performance-tooltip"), points = state.performancePoints || [], coords = state.performanceCoords || [];
if (!svg || !tooltip || !points.length || !coords.length) return;
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const { left, right, width, height } = PERFORMANCE_CHART;
const plotWidth = width - left - right;
const viewX = ((event.clientX - rect.left) / rect.width) * width;
const fraction = Math.min(1, Math.max(0, (viewX - left) / plotWidth));
const index = Math.min(points.length - 1, Math.max(0, Math.round(fraction * (points.length - 1))));
const point = points[index], coordinate = coords[index];
const pixelX = (coordinate[0] / width) * rect.width;
const pixelY = (coordinate[1] / height) * rect.height;
tooltip.innerHTML = `<strong>${point.count.toLocaleString()} request${point.count === 1 ? "" : "s"}</strong><span class="tooltip-errors${point.errors ? "" : " none"}">${(point.errors || 0).toLocaleString()} error${point.errors === 1 ? "" : "s"}</span><br>${escapeHtml(formatTime(point.at))}`;
tooltip.style.left = `${pixelX}px`;
tooltip.style.top = `${pixelY}px`;
tooltip.classList.remove("hidden");
const dot = document.querySelector("#performance-hover-dot");
if (dot) {
// preserveAspectRatio="none" stretches the viewBox, so compensate to keep the dot round.
dot.setAttribute("cx", coordinate[0].toFixed(1));
dot.setAttribute("cy", coordinate[1].toFixed(1));
dot.setAttribute("rx", (3.5 * (width / rect.width)).toFixed(2));
dot.setAttribute("ry", (3.5 * (height / rect.height)).toFixed(2));
dot.classList.remove("hidden");
}
}
$("#performance-sparkline")?.addEventListener("mousemove", showPerformanceTooltip);
$("#performance-sparkline")?.addEventListener("mouseleave", hidePerformanceTooltip);
// --- Administration > Users view ---------------------------------------------------------
function renderUsers() {
const counts = { administrator: 0, standard: 0, viewer: 0, disabled: 0, archived: 0 };
state.users.forEach(user => { if (user.status === "active") counts[user.role] = (counts[user.role] || 0) + 1; else if (counts[user.status] !== undefined) counts[user.status] += 1; });
@@ -308,13 +446,15 @@ function renderUsers() {
const roleLabel = user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User";
const lifecycle = user.status === "archived" ? `<button class="button secondary" data-user-action="status" data-value="active">Restore</button>` : `<button class="button secondary danger-text" data-user-action="status" data-value="archived">Archive</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 ? `<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>`;
}).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 => { 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); } }); 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); });
}
// --- Account panel (profile, MFA status) --------------------------------------------------
function renderAccount() {
if (!state.user) return;
$("#account-display-name").textContent = state.user.displayName || "—";
@@ -328,14 +468,19 @@ function renderAccount() {
$("#account-mfa-recovery").classList.toggle("hidden", !enabled);
}
// --- View routing: what data to (re)load and what to show for state.view ------------------
async function loadFeatureView() {
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 === "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?.();
restoreAdminTab();
}
// render() -- the main view switcher. Shows/hides each top-level section based on
// state.view, and for the Hosted/Proxy "management" view, renders the card grid,
// empty state, and summary indicator bar directly.
function render() {
const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view;
if (location.hash !== `#${viewHash}`) history.pushState(null, "", `${location.pathname}${location.search}#${viewHash}`);
@@ -348,8 +493,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");
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");
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
$("#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 activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups", adminApiActive = activeAdminTab === "api";
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || adminApiActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates" || !canAdmin()); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
if (overview) {
$("#page-title").textContent = "Dashboard";
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
@@ -357,9 +503,9 @@ function render() {
return;
}
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, security, 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];
$("#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") { 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";
@@ -371,6 +517,7 @@ function render() {
const items = state.view === "hosted" ? state.sites : state.proxies;
$("#site-grid").innerHTML = items.map(state.view === "hosted" ? hostedCard : proxyCard).join("");
$("#empty").classList.toggle("hidden", !state.loaded || items.length > 0);
$("#empty .empty-icon").textContent = state.view === "hosted" ? "↗" : "⇌";
$("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : "Create your first proxy host";
$("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : "Connect a domain to another container, application, or LAN service.";
$("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : "Proxy hosts";
@@ -385,21 +532,108 @@ function render() {
$("#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"}`;
}
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; }); } }
// --- Data refresh helpers ------------------------------------------------------------------
// Each entry is the state key a call populates and the fetch that populates it. refresh() (the
// full, unscoped fetch) and refreshCurrentView() (the page-scoped fetch, see below) both build
// their request list from this single map, so adding a new piece of shared state only ever means
// adding one line here.
const REFRESH_ENDPOINTS = {
sites: () => api("/api/sites"),
proxies: () => api("/api/proxies"),
redirects: () => api("/api/redirects"),
streams: () => api("/api/streams"),
accessLists: () => api("/api/access-lists"),
groups: () => canAdmin() ? api("/api/groups") : Promise.resolve([]),
dashboard: () => api("/api/dashboard"),
certificates: () => api("/api/certificates"),
};
// Which of the keys above each view actually renders. A view not listed here (certificates, logs,
// performance, administration, account, documentation) already loads its own data separately via
// loadFeatureView() and never called refresh() at all, so it isn't included. Overview intentionally
// lists everything: its attention list and the sidebar's per-section counts summarize the whole
// gateway, not one section of it, so a scoped fetch there would defeat the point of the page.
const VIEW_REFRESH_KEYS = {
overview: Object.keys(REFRESH_ENDPOINTS),
hosted: ["sites"],
proxies: ["proxies"],
streaming: ["streams"],
redirects: ["redirects"],
access: ["accessLists", "groups"],
};
async function refreshKeys(keys) {
const results = await Promise.allSettled(keys.map(key => REFRESH_ENDPOINTS[key]()));
results.forEach((result, index) => { if (result.status === "fulfilled") state[keys[index]] = result.value; });
}
function maybeRefreshPendingProxies() {
const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id);
if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); }
}
// The original, unscoped refresh -- fetches every shared list plus the dashboard and certificate
// summaries in one pass. Kept for cases that genuinely need everything at once: first page load
// (boot()) and the Overview page, whose attention list and counts summarize the entire gateway.
async function refresh() { await refreshKeys(Object.keys(REFRESH_ENDPOINTS)); state.loaded = true; render(); window.renderExtendedViews?.(); maybeRefreshPendingProxies(); }
// The page-scoped refresh: fetches only the state a given view actually renders, instead of
// unconditionally re-fetching sites, proxies, redirects, streams, access lists, groups, the full
// dashboard snapshot, and certificates every single time -- regardless of which one page the user
// is looking at. This was the original, most direct cause behind "refreshing one page refetches
// the whole site": every action (create, edit, toggle, delete) and every manual refresh called the
// same all-8-endpoints refresh() no matter which view triggered it. Sidebar badge counts for
// sections other than the current view are not re-fetched by this path and can go briefly stale
// until the next full refresh() (a fresh page load, or a visit to Overview) -- an intentional
// trade for not fetching data the current page doesn't display.
async function refreshCurrentView() {
const keys = VIEW_REFRESH_KEYS[state.view] || Object.keys(REFRESH_ENDPOINTS);
await refreshKeys(keys);
state.loaded = true; render(); window.renderExtendedViews?.();
if (keys.includes("proxies")) maybeRefreshPendingProxies();
}
// Polls just /api/proxies for upstream health that wasn't ready yet on the last refresh() --
// e.g. right after a page load or a new proxy, before its first health check has completed.
// This used to call the full refresh() (all 8 endpoints, including two redundant certificate
// walks via /api/dashboard + /api/certificates), up to 3 times in a row -- meaning a single
// pending proxy could quietly trigger 3 extra full-app refetches over 6 seconds. Since all it
// actually needs is fresh upstream status, it now re-fetches only /api/proxies.
async function refreshPendingProxies(ids = []) {
const pending = new Set(ids.map(String));
for (const delay of [1000, 2000, 3000]) {
if (!pending.size) return;
await new Promise(resolve => setTimeout(resolve, delay));
await refresh();
try {
state.proxies = await api("/api/proxies");
render();
window.renderExtendedViews?.();
} catch { /* Keep the last-known proxy list if this poll fails; the next delay tries again. */ }
for (const proxy of state.proxies) if (pending.has(String(proxy.id)) && proxy.upstream) pending.delete(String(proxy.id));
}
}
async function refreshDashboard() {
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
$("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
finally { button.disabled = false; button.classList.remove("spinning"); }
finally { /* no-op: the Live Health panel's own refresh icon was removed in favor of the page-level refresh button */ }
}
// Populates the Dashboard's hero panel (CPU/memory/swap/disk/network/uptime) directly from
// /api/system/health, the same call and the same renderHeroPanel() the Administration > System
// tab's hero uses, so the two can never show different numbers for the same live stat again.
// Uptime uses sixthSlot: "uptime" here (the System tab uses the default "throughput" slot instead,
// since the Dashboard already has its own live-requests chip elsewhere -- see below). There's no
// separate ticker or anchor for Uptime anymore: formatDuration() only ever shows minute-level
// granularity, so refreshing it on this same 7s poll as everything else is all the precision the
// display needs, and it removes a whole class of ticker/anchor race-condition bugs for free.
async function refreshDashboardHero() {
try {
const health = await api("/api/system/health");
window.renderHeroPanel?.("dashboard-hero", health, { sixthSlot: "uptime" });
// /api/system/health already computes throughput.liveRequests (the hero just doesn't display
// it here, since the Dashboard shows it in its own chip instead -- see sixthSlot above).
// Reuse that number to keep the chip on the same 7s cadence as the hero, instead of leaving it
// on the separate 30s refreshDashboard() timer, which was the actual bug being reported here.
const throughputTotal = $("#dash-throughput-total"); if (throughputTotal && health.throughput) throughputTotal.textContent = health.throughput.liveRequests ?? 0;
}
catch { /* Hero keeps its last-known values if a poll fails -- same behavior as the System tab's own hero. */ }
}
// --- Boot: session check, initial routing, periodic health/update checks -------------------
function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); }
async function boot() {
const requestedHash = location.hash.slice(1); state.adminTab = requestedHash.startsWith("administration/") ? requestedHash.split("/")[1] || "users" : "users"; if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
@@ -408,42 +642,121 @@ async function boot() {
$("#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.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"}`;
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}`;
$("#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)); }, 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);
// 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() {
if (state.updateAvailable || !state.loadedVersion) return;
const config = await api("/api/config");
if (config.version && config.version !== state.loadedVersion) { state.updateAvailable = true; $("#update-banner").classList.remove("hidden"); }
}
// --- Update-available banner --------------------------------------------------------------
$("#update-banner-refresh").addEventListener("click", () => location.reload());
$("#update-banner-dismiss").addEventListener("click", () => { $("#update-banner").classList.add("hidden"); state.updateAvailable = false; });
// --- Login, MFA login, first-run setup, and logout -----------------------------------------
$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { const result = await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); if (result?.mfaRequired) { $("#login-form").classList.add("hidden"); $("#mfa-login-form").classList.remove("hidden"); $("#mfa-login-form [name=code]").focus(); return; } event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#login-error").textContent = error.message; } });
$("#mfa-login-form").addEventListener("submit", async event => { event.preventDefault(); $("#mfa-login-error").textContent = ""; try { const response = await fetch("/api/login/mfa", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || "That code didn't match. Try again."); event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#mfa-login-error").textContent = error.message; } });
$("#mfa-login-cancel").addEventListener("click", () => { $("#mfa-login-form").reset(); $("#mfa-login-error").textContent = ""; $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#login-form").elements.password.value = ""; setTimeout(() => $("#login-form").elements.password.focus(), 0); });
$("#setup-form").addEventListener("submit", async event => { event.preventDefault(); $("#setup-error").textContent = ""; try { await api("/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); $("#setup-dialog").close(); event.target.reset(); await boot(); showLogin("Administrator account saved. Sign in with your finalized credentials."); } catch (error) { $("#setup-error").textContent = error.message; } });
$("#setup-dialog").addEventListener("cancel", event => event.preventDefault());
$("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); });
$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message); } finally { button.disabled = false; button.textContent = "Run certificate check"; } });
$("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; });
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message)); } });
// --- Dashboard actions: run certificate check, download support report, jump to
// an attention item's view -------------------------------------------------------------
$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; button.textContent = "Run certificate check"; } });
$("#download-support")?.addEventListener("click", () => { location.href = "/api/support-report"; });
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { const [view, adminTab] = target.split("/"); state.view = view; if (view === "administration" && adminTab) state.adminTab = adminTab; render(); loadFeatureView().catch(error => toast(error.message, "error")); } });
$("#attention-list").addEventListener("click", async event => {
const button = event.target.closest("[data-drift-resync]");
if (!button) return;
button.disabled = true; button.textContent = "Resyncing…";
try {
await api("/api/gateway/resync", { method: "POST" });
toast("Gateway configuration re-synced.");
await refresh();
} catch (error) { toast(error.message, "error"); button.disabled = false; button.textContent = "Resync now"; }
});
// --- Primary navigation (sidebar view switching) -------------------------------------------
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); });
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message, "error")); } }));
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message, "error")); });
// --- Logs & Performance filter controls -----------------------------------------------------
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error")));
// Generic page-scoped refresh button, shown on every view except Logs (which already has its own
// "Refresh logs" button wired to loadFeatureView()). Uses refreshCurrentView() for the shared-list
// views (Overview, Hosted, Proxy Hosts, Streaming, Redirects, Access Lists) so it fetches only
// what that page renders, and falls back to loadFeatureView() for every other view (Certificates,
// Performance, Administration, Account, Documentation), which already load their own data scoped
// to themselves.
$("#refresh-view").addEventListener("click", async () => {
const button = $("#refresh-view"); button.disabled = true; button.classList.add("spinning");
try {
if (state.view in VIEW_REFRESH_KEYS || state.view === "overview") await refreshCurrentView();
else await loadFeatureView();
toast("Refreshed.");
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; button.classList.remove("spinning"); }
});
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
// --- Performance: themed error-breakdown popup, replacing the old hover tooltip ------------
function showErrorBreakdown(host, total, breakdown) {
let dialog = document.querySelector("#error-breakdown-dialog");
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "error-breakdown-dialog"; document.body.append(dialog); }
// Client (4xx) and server (5xx) failures mean very different things, so they are grouped
// and coloured separately instead of appearing as one flat red list.
const client = breakdown.filter(item => Number(item.status) < 500), server = breakdown.filter(item => Number(item.status) >= 500);
const rowsFor = (items, kind) => items.map(item => `<div class="error-breakdown-row ${kind}"><span>${escapeHtml(item.status)}</span><span>${item.count.toLocaleString()}</span></div>`).join("");
const clientTotal = client.reduce((sum, item) => sum + item.count, 0), serverTotal = server.reduce((sum, item) => sum + item.count, 0);
const sections = [
client.length ? `<p class="error-breakdown-group">Client errors · 4xx · ${clientTotal.toLocaleString()}</p><div class="error-breakdown-list">${rowsFor(client, "client-error")}</div>` : "",
server.length ? `<p class="error-breakdown-group">Server errors · 5xx · ${serverTotal.toLocaleString()}</p><div class="error-breakdown-list">${rowsFor(server, "server-error")}</div>` : ""
].join("");
dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.</p>${sections || '<p class="quiet-state">No status codes recorded.</p>'}<div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
dialog.showModal();
}
function showTopPaths(host, paths) {
let dialog = document.querySelector("#top-paths-dialog");
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "top-paths-dialog"; document.body.append(dialog); }
const rows = paths.map(item => `<div class="top-paths-row"><span title="${escapeHtml(item.uri)}">${escapeHtml(item.uri)}</span><span>${item.count.toLocaleString()}</span></div>`).join("");
dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">The top 10 most requested paths on this domain in the last 24 hours.</p><div class="top-paths-list">${rows || '<div class="top-paths-row"><span>No requests recorded.</span><span>0</span></div>'}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
dialog.showModal();
}
$("#performance-rows").addEventListener("click", event => {
const errorButton = event.target.closest("[data-error-host]");
if (errorButton) { const entry = state.performanceErrorBreakdowns[errorButton.dataset.errorHost]; if (entry) showErrorBreakdown(errorButton.dataset.errorHost, entry.total, entry.breakdown); return; }
const pathsButton = event.target.closest("[data-paths-host]");
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);
$("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs);
// --- "Create" dialog: opens the right create form/dialog for the current view --------------
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 === "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(); }
@@ -452,23 +765,30 @@ function openCreate() {
$("#create-form").reset(); $("#create-form").querySelectorAll("details").forEach(details => details.open = false); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal();
}
$("#open-create").addEventListener("click", openCreate);
// --- Global dialog/menu behavior (Escape to close menus, dialog close resets state) ---------
document.addEventListener("click", event => { if (event.target.closest(".create-trigger")) openCreate(); if (event.target.closest(".close-dialog")) event.target.closest("dialog").close(); if (!event.target.closest(".menu-wrap")) closeMenus(); });
document.addEventListener("keydown", event => { if (event.key === "Escape") closeMenus(); });
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message)));
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refreshCurrentView(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refreshCurrentView(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
// --- Health-check field visibility polish for the create forms ------------------------------
function ensureHostedHealthFields() { [document.querySelector("#create-form details"), document.querySelector("#settings-hosted-advanced")].forEach(details => { if (!details || details.querySelector("[name=healthEnabled]")) return; const access = details.querySelector("[name=accessListId]")?.closest("label"); if (!access) return; access.insertAdjacentHTML("afterend", '<label>Health-check path<input name="healthPath" value="/"></label><label>Health-check method<select name="healthMethod"><option value="GET">GET — retrieve a response</option><option value="HEAD">HEAD — headers only</option></select></label><label>Expected status<input name="healthExpected" value="200-499"><small>Examples: 200, 200,204, or 200-399.</small></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label>Retries<input name="healthRetries" type="number" min="0" max="3" value="0"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this site</span></label>'); }); }
setInterval(ensureHostedHealthFields, 300);
// --- Hosted/Proxy edit ( "Domain & TLS" / "Edit proxy host" ) settings dialog ---------------
function openSettings(kind, id) {
if (kind === "hosted") kind = "site";
const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset(); form.querySelectorAll("details").forEach(details => details.open = false);
$("#settings-title").textContent = kind === "proxy" ? "Edit proxy host" : "Domain & TLS"; $("#settings-name-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-target-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-advanced").classList.toggle("hidden", kind !== "proxy"); $("#settings-hosted-advanced").classList.toggle("hidden", kind !== "site");
$("#settings-title").textContent = kind === "proxy" ? "Edit proxy host" : "Domain & TLS"; $("#settings-name-wrap").classList.remove("hidden"); $("#settings-target-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-advanced").classList.toggle("hidden", kind !== "proxy"); $("#settings-hosted-advanced").classList.toggle("hidden", kind !== "site");
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") {
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");
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");
@@ -481,24 +801,61 @@ function openSettings(kind, id) {
document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom");
}
// --- Hosted/Proxy card actions: toggle / edit / delete / replace files / change icon --------
$("#site-grid").addEventListener("click", async event => {
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
closeMenus();
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status."); } }
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refreshCurrentView(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } }
if (action === "settings") openSettings(kind, card.dataset.id);
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
if (action === "icon") openIconPicker(kind, card.dataset.id);
if (action === "caddy-config") openCaddyConfig(kind === "proxy" ? "proxies" : "sites", card.dataset.id);
});
// --- Redirect card actions delegated from the site grid (menu open/close, edit/
// icon/delete/toggle) ---------------------------------------------------------------------
document.querySelector("#redirect-list")?.addEventListener("click", event => {
const card = event.target.closest(".redirect-card"); if (!card) 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; }
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
if (action === "caddy-config") { closeMenus(); openCaddyConfig("redirects", card.dataset.redirectId); }
});
$("#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; });
$("#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); } event.target.value = ""; state.pendingReplace = null; });
// --- "View Caddy config" popout --------------------------------------------------------------
// Two overlapping rectangles, inline so it inherits currentColor from .icon-button.
const COPY_ICON_SVG = '<svg class="copy-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="5.4" y="1.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect><rect x="1.4" y="5.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect></svg>';
function caddyConfigDialog() {
let dialog = document.querySelector("#caddy-config-dialog");
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "caddy-config-dialog"; document.body.append(dialog); }
return dialog;
}
async function openCaddyConfig(kind, id) {
const dialog = caddyConfigDialog();
dialog.innerHTML = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>Loading…</h2></div></div><p class="muted">Reading the deployed configuration for this route.</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>';
if (!dialog.open) dialog.showModal();
try {
const result = await api(`/api/${kind}/${encodeURIComponent(id)}/caddy-config`);
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>${escapeHtml(result.name)}</h2></div><button type="button" class="icon-button" id="copy-caddy-config" aria-label="Copy configuration" title="Copy configuration">${COPY_ICON_SVG}</button></div><p class="muted">This is the exact block Site Gateway writes into the Caddyfile for this route, annotated with what each directive does.</p><pre class="caddy-config-pre">${escapeHtml(result.config)}</pre><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
dialog.querySelector("#copy-caddy-config").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(result.config); toast("Configuration copied."); }
catch { toast("Your browser blocked clipboard access.", "error"); }
});
} catch (error) {
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>Not available</h2></div></div><p class="muted">${escapeHtml(error.message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
}
}
window.openCaddyConfig = openCaddyConfig;
// --- Delete confirmation dialog and replace-files handler ------------------------------------
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await 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; });
// --- Icon picker dialog: search, upload, URL, and reset-to-fallback -------------------------
function openIconPicker(kind, id) {
state.iconTarget = { kind, id }; $("#icon-search").value = ""; $("#icon-url").value = ""; $("#icon-upload").value = ""; $("#icon-error").textContent = ""; $("#icon-results").innerHTML = '<p class="quiet-state">Enter at least two characters to search.</p>'; $("#icon-dialog").showModal(); setTimeout(() => $("#icon-search").focus(), 0);
}
@@ -514,12 +871,13 @@ $("#icon-search").addEventListener("input", event => {
} catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; }
}, 280);
});
async function refreshIconTargetView() { await refresh(); if (state.iconTarget?.kind === "tokens") await window.loadApiTokens?.(); }
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 = "";
try {
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; }
}
$("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); });
@@ -527,15 +885,17 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa
$("#icon-upload").addEventListener("change", async event => {
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
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; }
});
$("#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; }
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";
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."); }
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 refreshIconTargetView(); toast("Icon URL saved."); }
catch (error) { $("#icon-error").textContent = error.message; }
});
// --- User management: create, edit (role/status), password reset, delete --------------------
$("#user-form").addEventListener("submit", async event => {
event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#user-error").textContent = "";
try {
@@ -544,6 +904,7 @@ $("#user-form").addEventListener("submit", async event => {
} catch (error) { $("#user-error").textContent = error.message; }
finally { button.disabled = false; }
});
function themedUserConfirm(message, title = "Confirm action") { let dialog = document.querySelector("#user-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "user-confirm-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>${escapeHtml(title)}</h2></div></div><p class="muted">${escapeHtml(message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button danger">Confirm</button></div></form>`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true })); }
$("#user-list").addEventListener("click", async event => {
const menuCard = event.target.closest(".user-card");
@@ -555,11 +916,18 @@ $("#user-list").addEventListener("click", async event => {
closeMenus();
state.passwordTarget = user.id; $("#password-form").reset(); $("#password-error").textContent = ""; $("#password-title").textContent = `Reset ${user.username} password`; $("#password-dialog").showModal(); return;
}
if (button.dataset.userAction === "mfa-disable") {
closeMenus();
if (!await themedUserConfirm(`Disable two-factor authentication for “${user.username}”? Theyll be able to sign in with just their password until they set it up again.`, "Disable 2FA")) return;
button.disabled = true;
try { await api(`/api/users/${user.id}/mfa/disable`, { method: "POST" }); await loadFeatureView(); toast("Two-factor authentication disabled."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; }
return;
}
if (button.dataset.userAction === "delete") {
closeMenus();
if (!await themedUserConfirm(`Permanently delete user “${user.username}”? This cannot be undone.`, "Delete user")) return;
button.disabled = true;
try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message); } finally { button.disabled = false; }
try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; }
return;
}
button.disabled = true;
@@ -567,9 +935,10 @@ $("#user-list").addEventListener("click", async event => {
const body = button.dataset.userAction === "role" ? { role: button.dataset.value } : { status: button.dataset.value };
await api(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
await loadFeatureView(); toast("User updated.");
} catch (error) { toast(error.message); }
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; }
});
$("#password-form").addEventListener("submit", async event => {
event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#password-error").textContent = "";
try {
@@ -578,6 +947,8 @@ $("#password-form").addEventListener("submit", async event => {
} catch (error) { $("#password-error").textContent = error.message; }
finally { button.disabled = false; }
});
// --- Hash-based routing: back/forward and deep links (#view or #administration/tab) ---------
window.addEventListener("hashchange", () => {
if (!state.user) return; // Not logged in yet; boot() handles initial routing.
const requestedHash = location.hash.slice(1);
@@ -585,10 +956,13 @@ window.addEventListener("hashchange", () => {
if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
state.view = location.hash.slice(1) || "overview";
render();
loadFeatureView().catch(error => toast(error.message));
loadFeatureView().catch(error => toast(error.message, "error"));
});
boot().catch(error => toast(error.message));
boot().catch(error => toast(error.message, "error"));
// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------
function syncUpstreamTlsControls(form) {
if (!form || !form.elements.target) return;
const targets = [form.elements.target.value, form.elements.upstreamsText?.value || ""].join("\n").split(/\n+/).map(value => value.trim()).filter(Boolean);
@@ -602,14 +976,22 @@ function syncUpstreamTlsControls(form) {
const lbPolicy = form.elements.lbPolicy;
if (lbPolicy) { const poolTargets = String(form.elements.upstreamsText?.value || "").split("\n").map(value => value.trim()).filter(Boolean); const multi = poolTargets.length > 1; lbPolicy.disabled = !multi; lbPolicy.closest("label")?.classList.toggle("control-disabled", !multi); if (!multi) lbPolicy.value = "random"; }
}
document.addEventListener("input", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
document.addEventListener("change", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
document.querySelectorAll("#proxy-form,#settings-form").forEach(form => syncUpstreamTlsControls(form));
// --- Misc global click delegation (create triggers, generic [data-action] handlers) ---------
document.addEventListener("click", event => { if (event.target.closest(".create-trigger,[data-action=edit],[data-card-action=edit]")) setTimeout(() => { syncUpstreamTlsControls(document.querySelector("#proxy-form")); syncUpstreamTlsControls(document.querySelector("#settings-form")); }, 0); });
document.addEventListener("click", event => { const trigger = event.target.closest("[data-action=settings],[data-card-action=settings]"); if (!trigger) return; setTimeout(() => { const item = (state.editing?.kind === "proxy" ? state.proxies : state.sites).find(value => value.id === state.editing?.id); if (!item) return; const scope = state.editing.kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; const checkbox = document.querySelector(`${scope} [name="healthEnabled"]`); if (checkbox) checkbox.checked = !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false"); }, 0); });
// --- Access Lists: periodic live refresh while that view is open ----------------------------
setInterval(() => { if (state.view !== 'access') return; const items = state.accessLists || []; const enabled = items.filter(item => item.enabled !== false).length; const disabled = items.length - enabled; $('#running-count').textContent = enabled; $('#disabled-count').textContent = disabled; $('#error-count').textContent = 0; $('#running-label').textContent = enabled ? 'Enabled' : 'None enabled'; $('#disabled-label').textContent = disabled ? 'Disabled' : 'None disabled'; $('#error-label').textContent = 'No issues'; $('#running-dot').className = `status-dot ${enabled ? 'running' : 'inactive'}`; $('#disabled-dot').className = `status-dot ${disabled ? 'disabled' : 'inactive'}`; $('#error-dot').className = 'status-dot inactive'; $('.port-note').classList.add('hidden'); }, 500);
function renderDashboardJobsSafe(system) { const slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="health-grid">${(system.jobs || []).map(job => `<div class="health-tile"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
// --- Account: change password form ------------------------------------------------------------
$("#account-password-form").addEventListener("submit", async event => {
event.preventDefault();
$("#account-password-error").textContent = "";
@@ -622,6 +1004,9 @@ $("#account-password-form").addEventListener("submit", async event => {
} catch (error) { $("#account-password-error").textContent = error.message; }
});
// --- MFA: password re-confirmation dialog used before disabling MFA or
// regenerating recovery codes --------------------------------------------------------------
let mfaPasswordResolve = null;
function requestMfaPassword(title, heading) {
$("#mfa-password-title").textContent = title;
@@ -640,6 +1025,8 @@ $("#mfa-password-form").addEventListener("submit", event => {
});
$("#mfa-password-cancel").addEventListener("click", () => { $("#mfa-password-dialog").close(); mfaPasswordResolve?.(null); mfaPasswordResolve = null; });
// --- MFA: enable / setup / confirm flow --------------------------------------------------------
$("#account-mfa-enable").addEventListener("click", async () => {
try {
const result = await api("/api/account/mfa/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
@@ -648,7 +1035,7 @@ $("#account-mfa-enable").addEventListener("click", async () => {
$("#mfa-setup-error").textContent = "";
$("#mfa-setup-confirm-form").reset();
$("#mfa-setup-dialog").showModal();
} catch (error) { toast(error.message); }
} catch (error) { toast(error.message, "error"); }
});
$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); });
$("#mfa-setup-confirm-form").addEventListener("submit", async event => {
@@ -661,12 +1048,14 @@ $("#mfa-setup-confirm-form").addEventListener("submit", async event => {
state.user.mfaEnabled = true;
renderAccount();
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
$("#mfa-recovery-dialog").showModal();
requestAnimationFrame(() => $("#mfa-recovery-dialog").showModal());
toast("Two-factor authentication enabled.");
} catch (error) { $("#mfa-setup-error").textContent = error.message; }
});
$("#mfa-recovery-done").addEventListener("click", () => { $("#mfa-recovery-dialog").close(); });
// --- MFA: disable and regenerate recovery codes -------------------------------------------------
$("#account-mfa-disable").addEventListener("click", async () => {
const password = await requestMfaPassword("Disable two-factor authentication", "Confirm your password to continue");
if (!password) return;
@@ -675,7 +1064,7 @@ $("#account-mfa-disable").addEventListener("click", async () => {
state.user.mfaEnabled = false;
renderAccount();
toast("Two-factor authentication disabled.");
} catch (error) { toast(error.message); }
} catch (error) { toast(error.message, "error"); }
});
$("#account-mfa-recovery").addEventListener("click", async () => {
const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue");
@@ -683,7 +1072,7 @@ $("#account-mfa-recovery").addEventListener("click", async () => {
try {
const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
$("#mfa-recovery-dialog").showModal();
requestAnimationFrame(() => $("#mfa-recovery-dialog").showModal());
toast("Recovery codes regenerated. Your old codes no longer work.");
} catch (error) { toast(error.message); }
} catch (error) { toast(error.message, "error"); }
});
+595 -27
View File
File diff suppressed because one or more lines are too long
+106 -41
View File
File diff suppressed because one or more lines are too long
+121
View File
@@ -0,0 +1,121 @@
// ============================================================================================
// select-enhance.js -- replaces native <select> popups with a custom-drawn, dark-themed
// listbox (Task #20). The `color-scheme` CSS hint does not reliably theme native select
// popups across real browsers/engines, so this draws its own. The underlying native <select>
// is kept in the DOM, fully intact for its `name`/`value`/form submission and for every
// existing piece of code that reads or sets `form.elements[name].value` or listens for a
// native "change" event -- none of that code needed to change. Only direct user interaction
// with the native popup is replaced.
// ============================================================================================
function enhanceSelects() {
document.querySelectorAll("select").forEach(select => {
if (select.dataset.enhanced) return;
if (select.closest(".custom-select")) return;
select.dataset.enhanced = "1";
const wrap = document.createElement("span");
wrap.className = "custom-select";
select.replaceWith(wrap);
wrap.append(select);
// The native element stays for value/name/form/event-listener compatibility, but is
// removed from the tab order and made unclickable -- the trigger below is what users
// and assistive tech actually interact with.
select.tabIndex = -1;
select.setAttribute("aria-hidden", "true");
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "custom-select-trigger";
trigger.setAttribute("role", "combobox");
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
wrap.append(trigger);
const syncTriggerLabel = () => {
const option = select.options[select.selectedIndex];
trigger.textContent = option ? option.textContent : "";
trigger.disabled = select.disabled;
};
syncTriggerLabel();
let menu = null;
const closeMenu = () => {
if (!menu) return;
menu.remove();
menu = null;
trigger.setAttribute("aria-expanded", "false");
};
const commit = (option, index) => {
select.selectedIndex = index;
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
syncTriggerLabel();
closeMenu();
trigger.focus();
};
const openMenu = () => {
if (menu || select.disabled) return;
menu = document.createElement("div");
menu.className = "custom-select-menu";
menu.setAttribute("role", "listbox");
const rect = trigger.getBoundingClientRect();
menu.style.left = `${rect.left}px`;
menu.style.top = `${rect.bottom + 4}px`;
menu.style.width = `${rect.width}px`;
[...select.options].forEach((option, index) => {
const item = document.createElement("div");
item.className = "custom-select-option" + (index === select.selectedIndex ? " is-selected" : "") + (option.disabled ? " is-disabled" : "");
item.setAttribute("role", "option");
item.textContent = option.textContent;
if (option.disabled) item.setAttribute("aria-disabled", "true");
else item.addEventListener("click", () => commit(option, index));
menu.append(item);
});
// Dialogs render in the browser's top layer, which sits above ordinary DOM regardless
// of z-index -- a menu appended to <body> for a select inside a <dialog> would render
// beneath it. Appending into the dialog keeps the menu in the same stacking context.
(select.closest("dialog") || document.body).append(menu);
trigger.setAttribute("aria-expanded", "true");
const highlighted = () => menu?.querySelector(".is-highlighted") || menu?.querySelector(".is-selected") || menu?.firstElementChild;
menu.querySelector(".is-selected")?.classList.add("is-highlighted");
menu._moveHighlight = delta => {
const items = [...menu.querySelectorAll(".custom-select-option:not(.is-disabled)")];
if (!items.length) return;
const current = menu.querySelector(".is-highlighted");
let index = current ? items.indexOf(current) : -1;
index = (index + delta + items.length) % items.length;
menu.querySelectorAll(".is-highlighted").forEach(item => item.classList.remove("is-highlighted"));
items[index].classList.add("is-highlighted");
items[index].scrollIntoView({ block: "nearest" });
};
menu._chooseHighlighted = () => {
const item = highlighted();
if (!item) return;
const index = [...menu.children].indexOf(item);
if (index >= 0 && !select.options[index]?.disabled) commit(select.options[index], index);
};
};
trigger.addEventListener("click", () => (menu ? closeMenu() : openMenu()));
trigger.addEventListener("keydown", event => {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) event.preventDefault();
if (event.key === "ArrowDown") { if (!menu) openMenu(); else menu._moveHighlight(1); }
else if (event.key === "ArrowUp") { if (!menu) openMenu(); else menu._moveHighlight(-1); }
else if (event.key === "Enter" || event.key === " ") { if (!menu) openMenu(); else menu._chooseHighlighted(); }
else if (event.key === "Escape") closeMenu();
else if (event.key === "Tab") closeMenu();
});
document.addEventListener("click", event => { if (menu && !wrap.contains(event.target) && !menu.contains(event.target)) closeMenu(); }, true);
wrap.__syncTriggerLabel = syncTriggerLabel;
});
// Keep every already-enhanced trigger's label in sync with code elsewhere that sets
// `select.value`/`select.selectedIndex` directly (e.g. renderBackups() populating the
// scheduled-backup form from saved settings) without going through the custom menu.
document.querySelectorAll(".custom-select").forEach(wrap => wrap.__syncTriggerLabel?.());
}
document.addEventListener("DOMContentLoaded", enhanceSelects);
setInterval(enhanceSelects, 150);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+884 -165
View File
File diff suppressed because one or more lines are too long
+850 -89
View File
File diff suppressed because one or more lines are too long
+52 -6
View File
@@ -64,12 +64,20 @@ export async function openStorage(dataDir, backupsDir) {
CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id));
CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS audit_events_instance_created ON audit_events(instance_id,created_at DESC);
CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
CREATE TABLE IF NOT EXISTS api_tokens (id TEXT PRIMARY KEY, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, name TEXT NOT NULL, token_hash TEXT NOT NULL, prefix TEXT NOT NULL, owner_user_id TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'full', session_version TEXT, created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT, revoked_at TEXT);
CREATE INDEX IF NOT EXISTS api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS api_tokens_instance ON api_tokens(instance_id);
CREATE TABLE IF NOT EXISTS backup_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, type TEXT NOT NULL, filename TEXT, backup_type TEXT, size_bytes INTEGER, actor_user_id TEXT, created_at TEXT NOT NULL, safety_backup_filename TEXT, status TEXT NOT NULL DEFAULT 'success', error_message TEXT);
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 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();
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);
@@ -115,12 +123,16 @@ export async function openStorage(dataDir, backupsDir) {
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
SUM(CASE WHEN at>=? THEN COALESCE(size,0) ELSE 0 END) AS hourBytes,
COUNT(DISTINCT CASE WHEN at>=? THEN remote_ip END) AS hourVisitors,
COUNT(*) AS dayRequests,
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
AVG(duration_ms) AS dayAvgMs
AVG(duration_ms) AS dayAvgMs,
SUM(COALESCE(size,0)) AS dayBytes,
COUNT(DISTINCT remote_ip) AS dayVisitors
FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!=''
GROUP BY host ORDER BY dayRequests DESC
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
`).all(hourCutoff, hourCutoff, hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
}
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
@@ -134,17 +146,51 @@ export async function openStorage(dataDir, backupsDir) {
const bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
const cutoff = new Date(Date.now() - windowMs).toISOString();
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
const rows = db.prepare(`SELECT at,status FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
const buckets = new Map();
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); }
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; const entry = buckets.get(bucketStart) || { count: 0, errors: 0 }; entry.count += 1; if (Number(row.status) >= 400) entry.errors += 1; buckets.set(bucketStart, entry); }
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
const points = [];
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) { const entry = buckets.get(bucket); points.push({ at: new Date(bucket).toISOString(), count: entry?.count || 0, errors: entry?.errors || 0 }); }
return points;
}
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
function listAudit(filters = {}, instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT id,actor_id,action,status,details,created_at FROM audit_events WHERE instance_id=? ORDER BY id DESC LIMIT 500").all(instanceId); return rows.filter(row => (!filters.user || row.actor_id === filters.user) && (!filters.action || row.action.toLowerCase().includes(filters.action.toLowerCase())) && (!filters.status || row.status === filters.status)).map(row => ({ ...row, details: row.details ? JSON.parse(row.details) : null })); }
// 95th-percentile latency per host. SQLite has no percentile aggregate, so the
// durations come back pre-sorted per host and the index is picked in JavaScript.
function performancePercentiles(percentile = 0.95, instanceId = LOCAL_INSTANCE_ID) {
const hourCutoff = new Date(Date.now() - 3600000).toISOString(), dayCutoff = new Date(Date.now() - 86400000).toISOString();
const rows = db.prepare("SELECT host,at,duration_ms AS durationMs FROM access_events WHERE instance_id=? AND at>=? AND duration_ms IS NOT NULL AND host IS NOT NULL AND host!='' ORDER BY host, duration_ms").all(instanceId, dayCutoff);
const pick = values => { if (!values.length) return null; const index = Math.min(values.length - 1, Math.max(0, Math.ceil(percentile * values.length) - 1)); return Math.round(values[index]); };
const byHost = new Map();
for (const row of rows) { if (!byHost.has(row.host)) byHost.set(row.host, { day: [], hour: [] }); const entry = byHost.get(row.host); entry.day.push(row.durationMs); if (row.at >= hourCutoff) entry.hour.push(row.durationMs); }
return Object.fromEntries([...byHost].map(([host, entry]) => [host, { hourP95: pick(entry.hour), dayP95: pick(entry.day) }]));
}
function performanceTopPaths(limit = 10, instanceId = LOCAL_INSTANCE_ID) {
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
const cap = Math.max(1, Math.min(Number(limit) || 10, 50));
const rows = db.prepare("SELECT host,uri,COUNT(*) AS count FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!='' GROUP BY host,uri ORDER BY host, count DESC").all(instanceId, dayCutoff);
const byHost = new Map();
for (const row of rows) { const list = byHost.get(row.host) || []; if (list.length < cap) list.push({ uri: row.uri || "/", count: row.count }); byHost.set(row.host, list); }
return Object.fromEntries(byHost);
}
function performanceSlowest(host = "", hours = 6, limit = 20, instanceId = LOCAL_INSTANCE_ID) {
const cutoff = new Date(Date.now() - Math.max(1, Number(hours) || 6) * 3600000).toISOString();
return db.prepare("SELECT host,uri,method,status,duration_ms AS durationMs,at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?) AND duration_ms IS NOT NULL ORDER BY duration_ms DESC LIMIT ?").all(instanceId, cutoff, host, host, Math.max(1, Math.min(Number(limit) || 20, 50)));
}
// --- API tokens. Dedicated table (not the generic JSON-collection pattern) because
// 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,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 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 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
// 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 listBackupEvents(limit = 500, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,type,filename,backup_type AS backupType,size_bytes AS sizeBytes,actor_user_id AS actorUserId,created_at AS createdAt,safety_backup_filename AS safetyBackupFilename,status,error_message AS errorMessage FROM backup_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 500, 500))); }
function backupTo(filename) { try { fs.rmSync(filename, { force: true }); db.exec(`VACUUM INTO '${String(filename).replaceAll("'", "''")}'`); } catch (error) { throw new Error(`Could not create a consistent SQLite backup: ${error.message}`); } }
if (isNew) {
@@ -170,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; }
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, 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() };
}