Compare commits

..

11 Commits

Author SHA1 Message Date
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
9 changed files with 444 additions and 81 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.15.0-62E6A7">
<img alt="Version" src="https://img.shields.io/badge/version-0.16.8-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
+56 -6
View File
@@ -6,15 +6,13 @@
`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, and scopes out (but does not yet ship) a larger set of previously-discussed features tracked below as follow-up work.
`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 and a "Resync now" callout appears under Administration \u2192 Default site, both driven by a new `POST /api/gateway/resync` route that re-runs the normal Caddy sync and clears the flag.
- **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.
### Follow-up work carried from this release's planning
A larger feature set was scoped for `v0.14.0` and intentionally deferred rather than shipped partially-verified. These remain on the roadmap for a future release:
`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.
@@ -22,6 +20,38 @@ A larger feature set was scoped for `v0.14.0` and intentionally deferred rather
- **"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
@@ -44,6 +74,7 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
- 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.
### Certificates and TLS
@@ -55,7 +86,10 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
- 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, and per-route breakdowns — the host filter applies to the throughput table as well as the trend chart, average response times display in seconds once they pass 1000ms, and per-domain error counts open a themed breakdown by status code.
- 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.
@@ -65,6 +99,10 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
- 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.
- 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.
### Brand and docs
@@ -94,3 +132,15 @@ Roughly in priority order:
- Ports 80 and 443 must not already be owned by another reverse proxy on the same host.
- 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.
`v0.16.3` fixes the System tab's real population and layout bugs reported after v0.16.2 went live. Root cause of the empty sections and the tab "flickering" on refresh: the System panel is created dynamically (like Groups, Audit log, and API Access) the first time `refresh()` runs after login, but the app's render order calls the tab-visibility toggle *before* that panel exists — so a brand-new panel is created already carrying the `hidden` class, and `renderSystemStatus()` was guarded to skip populating anything while its panel was hidden. Result: on first render nothing gets filled in, and only after the *next* periodic poll (once the panel exists and the visibility toggle can find and unhide it) does it get one more chance — which looked like the tab disappearing and reappearing. Fix: `renderSystemStatus()` now always populates its content regardless of the panel's current visibility, matching how every other dynamically-created admin panel (Groups, Retention, API Access) already behaves. Also added the missing spacing between the System tab's stacked `.dashboard-panel` cards (`[data-admin-panel="system"]>.dashboard-panel+.dashboard-panel{margin-top:18px}`) — the generic `.settings-panel` wrapper never had a gap rule for its children, so the cards were rendering edge-to-edge.
`v0.16.4` rewords the Reload & Restart section's "Docker socket not detected" message so it no longer reads as a duplicate of the Docker container-selection message elsewhere on the System tab. Both checks are independent (one gates the proxy-target container picker, the other gates whether Site Gateway can confirm this container will actually come back up before offering a restart), but they previously used the exact same sentence, which looked like a copy-paste mistake. No behavior change — the Restart button is still disabled under the same conditions as before.
`v0.16.5` gives the Docker container-selection status its own `.health-tile` card (matching `BACKUP_PASSWORD` directly above it) instead of a plain paragraph — the two Environment/Integrations rows now look consistent whether Docker's socket is mounted or not. Also adds breathing room between the Reload & Restart buttons and the status line beneath them, which was sitting flush against the button row.
`v0.16.6` fixes the real cause of the System tab's Scheduled jobs section always showing "No scheduled jobs reported.": the dashboard API nests job data under `dashboard.system.jobs`, but the System tab was reading `dashboard.jobs` — one level too shallow, so it was always undefined regardless of what the server returned. Also fixes the Docker socket status tile rendering wider than the `BACKUP_PASSWORD` tile above it — it wasn't wrapped in the same `.health-grid` container, so it spanned the full panel width instead of matching the two-column tile layout used everywhere else on the System tab.
`v0.16.7` fixes the real cause of the System tab visibly blinking in and out on every page reload: unlike every other Administration tab (Users, Groups, Gateway defaults, Audit log, Backup & restore, Logs & Retention, Danger Zone — all static HTML present from the first paint), System and API Access were built entirely by JavaScript after the initial data fetch completed, so there was a real window on every reload where every other tab was already visible and these two genuinely were not there yet. Confirmed via a screenshot taken mid-reload showing exactly that. Fix: gave System and API Access the same static tab-button-and-panel shell every other tab already has, so they're present immediately; their content still fills in a moment later via JavaScript, same as every other tab already does. Also reworked the Scheduled Jobs section to match the Storage section's card styling (health-tile grid instead of a plain list) and added last-run timestamps where the server tracks them (scheduled backups, log pruning, public IP checks, configuration drift checks).
`v0.16.8` reworks the System tab's Integrations section: the `BACKUP_PASSWORD` and Docker socket status tiles now share a single two-column grid and sit side by side, instead of each occupying its own separate grid and leaving an empty column next to it. Dropped the redundant "Docker container selection" heading text since the tile's own "Docker socket" label already says the same thing. Moved the Sync section's "Resync now" button out of the panel heading row and into its own row below the status text, matching the layout every other actioned section (like Reload & Restart) already uses, instead of crowding the button into the title row. Also added real last-run tracking for the two jobs that previously always showed "No run recorded yet": Upstream checks and Access-log import now record a timestamp every time their interval actually runs.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.15.0",
"version": "0.16.8",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+18 -4
View File
@@ -189,11 +189,12 @@ function renderDashboard() {
$("#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";
applyTileAccents(data);
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
$("#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";
@@ -226,7 +227,10 @@ function renderDashboard() {
$("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked";
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
$("#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><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>';
}
@@ -598,6 +602,16 @@ $("#logout").addEventListener("click", async () => { await fetch("/api/logout",
$("#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"); }); }
@@ -630,7 +644,7 @@ 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 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.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 => {
+105 -35
View File
@@ -79,7 +79,7 @@ function renderBackups() {
const completeBackups = state.backups.filter(item => item.type === "complete"), configBackups = state.backups.filter(item => item.type === "configuration");
const summaryEl = document.querySelector("#backup-summary");
if (summaryEl) summaryEl.textContent = state.backups.length ? `${completeBackups.length} Complete (${formatBytes(completeBackups.reduce((sum, item) => sum + item.size, 0))}), ${configBackups.length} Configuration only (${formatBytes(configBackups.reduce((sum, item) => sum + item.size, 0))}).` : "";
document.querySelector("#backup-list").innerHTML = state.backups.length ? state.backups.map(item => `<article class="data-row backup-row" data-backup="${extendedEscape(item.filename)}"><span class="status-dot ${item.valid ? "running" : "error"}"></span><div><strong>${extendedEscape(item.filename)}</strong><small>${formatTime(item.createdAt)}</small></div><div><span class="chip type-${extendedEscape(item.type)}">${backupTypeLabel(item.type)}</span><small>Site Gateway ${extendedEscape(item.appVersion)}</small></div><div><strong>${formatBytes(item.size)}</strong><small>${item.valid ? "Verified manifest" : "Unreadable manifest"}</small></div><div class="row-actions"><a class="button secondary" href="/api/backups/${encodeURIComponent(item.filename)}/download">Download</a><button class="button secondary" data-backup-action="restore">Restore</button><button class="button secondary danger-text" data-backup-action="delete">Delete</button></div></article>`).join("") : '<p class="quiet-state padded">No stored backups yet.</p>';
document.querySelector("#backup-list").innerHTML = state.backups.length ? state.backups.map(item => `<article class="data-row backup-row" data-backup="${extendedEscape(item.filename)}"><span class="status-dot ${item.valid ? "running" : "error"}"></span><div><strong title="${extendedEscape(item.filename)}">${backupTypeLabel(item.type)} backup — ${formatTime(item.createdAt)}</strong><small>${formatBytes(item.size)}</small></div><div><span class="chip type-${extendedEscape(item.type)}">${backupTypeLabel(item.type)}</span><small>Site Gateway ${extendedEscape(item.appVersion)}</small></div><div><strong>${item.valid ? "Verified" : "Unreadable"}</strong><small>${item.valid ? "Manifest checks out" : "Manifest could not be read"}</small></div><div class="row-actions"><a class="button secondary" href="/api/backups/${encodeURIComponent(item.filename)}/download">Download</a><button class="button secondary" data-backup-action="restore">Restore</button><button class="button secondary danger-text" data-backup-action="delete">Delete</button></div></article>`).join("") : '<p class="quiet-state padded">No stored backups yet.</p>';
}
// Toggle the "configuration only" warning banner whenever scheduling or the
// backup type changes.
@@ -275,7 +275,7 @@ document.addEventListener("click", event => { if (event.target.closest(".create-
function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = "<span></span>"; actions.append(toggle); }); }
function decorateGroupCards() { document.querySelectorAll('[data-admin-panel="groups"] .group-card').forEach(card => { const group = state.groups.find(value => value.id === card.querySelector("[data-group-action]")?.dataset.groupId); if (!group) return; const icon = card.querySelector(".site-icon"); if (icon && icon.textContent.trim() === "GR") icon.innerHTML = featureIcon(group, "GR"); const menu = card.querySelector(".menu"); if (menu && !menu.querySelector("[data-group-action=icon]")) { const button = document.createElement("button"); button.dataset.groupAction = "icon"; button.dataset.groupId = group.id; button.textContent = "Change icon"; menu.prepend(button); } }); }
document.addEventListener("click", event => { const button = event.target.closest("[data-group-action=icon]"); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); openIconPicker("groups", button.dataset.groupId); }, true);
function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","retention","api","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["system","users","groups","defaults","audit","backups","retention","api","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); });
// --- Backup encryption password field: placeholder/visibility polish -------------
@@ -290,7 +290,7 @@ function renderEncryptionToggle() {
const available = Boolean(state.config && state.config.backup && state.config.backup.encryptionAvailable);
const savedEncrypt = Boolean(state.settings && state.settings.backups && state.settings.backups.encrypt);
encryptionToggle.className = "encryption-toggle";
let message = "Uses the container\u2019s <code>BACKUP_PASSWORD</code> value. Enable only after configuring that value.";
let message = "<code>BACKUP_PASSWORD</code> is configured \u2014 scheduled backups can be encrypted.";
if (!available && savedEncrypt) message = "This is enabled but <code>BACKUP_PASSWORD</code> is no longer configured \u2014 encrypted scheduled backups will fail until it\u2019s set again.";
else if (!available) message = "<code>BACKUP_PASSWORD</code> not configured \u2014 set it in the container\u2019s environment to enable encrypted scheduled backups.";
encryptionToggle.innerHTML = '<span class="field-label">Encrypt scheduled backups</span><span class="encryption-toggle-box"><input name="encrypt" type="checkbox"' + (available ? "" : " disabled") + (savedEncrypt ? " checked" : "") + '><span' + (!available ? ' class="warning-text"' : '') + '>' + message + '</span></span>';
@@ -326,27 +326,6 @@ document.addEventListener("click", async event => { const button = event.target.
document.addEventListener("click", event => { const button = event.target.closest('[data-retention-action="download"]'); if (!button) return; window.location.href = "/api/logs/download"; });
document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); try { const preview = await api("/api/logs/prune/preview"); const counts = preview.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + value, 0); let dialog = document.querySelector("#retention-prune-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "retention-prune-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Log Retention</p><h2>Confirm pruning</h2></div></div><p class="muted">This will remove records older than your saved retention periods.</p><p class="muted">Access: <strong>${counts.access || 0}</strong> · Activity: <strong>${counts.activity || 0}</strong> · Certificates: <strong>${counts.certificate || 0}</strong> · Security: <strong>${counts.security || 0}</strong> · Audit: <strong>${counts.audit || 0}</strong></p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button primary">Confirm pruning</button></div></form>`; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true })); if (result !== "confirm") return; const response = await api("/api/logs/prune", { method: "POST" }); toast(`Pruning completed. ${Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0)} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }, true);
// --- Config drift: show a resync callout in Administration > Default site when drift is detected --------
function renderConfigDriftCallout() {
const callout = document.querySelector("#config-drift-callout");
if (!callout) return;
const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift");
callout.hidden = !drift;
}
setInterval(renderConfigDriftCallout, 1000);
document.addEventListener("click", async event => {
const button = event.target.closest("#config-resync");
if (!button) return;
button.disabled = true;
try {
await api("/api/gateway/resync", { method: "POST" });
toast("Gateway configuration re-synced.");
await refresh();
renderConfigDriftCallout();
} catch (error) { toast(error.message); } finally { button.disabled = false; }
});
// ============================================================================================
// v0.15.0 additions: API access tokens, backup history, and the Docker container picker.
// ============================================================================================
@@ -467,16 +446,24 @@ async function renderBackupHistory() {
// saved value, so the integration can never be switched on without its prerequisite.
function renderDockerPanel() {
if (state.user?.role !== "administrator") return;
const panel = document.querySelector('[data-admin-panel="defaults"]'); if (!panel) return;
const scope = document.querySelector('[data-admin-panel="system"]'); if (!scope) return;
const envStatus = scope.querySelector("#system-env-status"), integrations = scope.querySelector(".system-integrations");
if (!envStatus || !integrations) return;
const socketMounted = state.config?.docker?.socketMounted === true;
const enabled = socketMounted && (state.settings?.dockerIntegration?.enabled === true || state.config?.docker?.enabled === true);
let section = panel.querySelector(".docker-integration-section");
if (!section) {
section = document.createElement("div");
section.className = "dashboard-panel docker-integration-section";
section.innerHTML = '<div class="panel-heading"><div><p class="eyebrow">Integrations</p><h2>Docker container selection</h2></div></div><p class="muted" id="docker-integration-help"></p><label class="check-control"><input id="docker-integration-toggle" type="checkbox"><span>Let Proxy and Streaming hosts pick a running container as their target</span></label>';
panel.append(section);
section.querySelector("#docker-integration-toggle").addEventListener("change", async event => {
let tile = envStatus.querySelector("#docker-integration-status");
if (!tile) {
tile = document.createElement("div");
tile.className = "health-tile";
tile.id = "docker-integration-status";
tile.innerHTML = '<span class="status-dot"></span><span class="health-tile-copy"><strong>Docker socket</strong><small id="docker-integration-help"></small></span>';
envStatus.append(tile);
}
let control = integrations.querySelector(".check-control");
if (!control) {
integrations.innerHTML = '<label class="check-control"><input id="docker-integration-toggle" type="checkbox"><span>Let Proxy and Streaming hosts pick a running container as their target</span></label>';
control = integrations.querySelector(".check-control");
control.querySelector("#docker-integration-toggle").addEventListener("change", async event => {
const checkbox = event.currentTarget;
checkbox.disabled = true;
try { state.settings = await api("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dockerIntegration: { enabled: checkbox.checked } }) }); toast(checkbox.checked ? "Container selection enabled." : "Container selection disabled."); }
@@ -484,13 +471,14 @@ function renderDockerPanel() {
finally { checkbox.disabled = false; renderDockerPanel(); decorateContainerPickers(); }
});
}
const toggle = section.querySelector("#docker-integration-toggle");
const toggle = integrations.querySelector("#docker-integration-toggle");
toggle.checked = enabled;
toggle.disabled = !socketMounted;
section.querySelector("#docker-integration-help").textContent = socketMounted
tile.querySelector(".status-dot").className = `status-dot ${socketMounted ? "running" : "idle"}`;
tile.querySelector("#docker-integration-help").textContent = socketMounted
? "Site Gateway reads the Docker socket read-only to list running containers, and only offers containers that share a Docker network with it."
: "Docker socket not detected — mount /var/run/docker.sock into this container to enable container selection.";
section.querySelector(".check-control").classList.toggle("is-disabled", !socketMounted);
control.classList.toggle("is-disabled", !socketMounted);
}
// Adds the "Pick from running containers" button beside every target field, and keeps its
// visibility in step with the integration's current state.
@@ -545,11 +533,93 @@ document.addEventListener("click", async event => {
});
// --- System tab: environment/integration status, storage, scheduled jobs, sync, restart --------
function renderSystemPanel() {
if (state.user?.role !== "administrator") return;
const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]');
if (!tabs || !users) return;
let tab = tabs.querySelector('[data-admin-tab="system"]');
if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "system"; tab.textContent = "System"; tabs.insertBefore(tab, tabs.firstChild); }
let panel = document.querySelector('[data-admin-panel="system"]');
if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "system"; panel.className = "settings-panel hidden"; users.parentElement.insertBefore(panel, users); }
if (!panel.dataset.ready) {
panel.dataset.ready = "1";
panel.innerHTML = [
'<div class="panel-heading"><div><h2>System</h2><p class="muted">What\u2019s configured, what\u2019s running, and what this deployment can do. Nothing here is customizable except the Docker toggle below and the action buttons \u2014 everything else is status.</p></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Integrations</h2></div></div><div id="system-env-status" class="health-grid"></div><div class="system-integrations"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Security status</h2></div></div><div id="system-security" class="health-grid"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Gateway</p><h2>Sync</h2></div></div><p id="system-sync-status" class="muted"></p><div class="row-actions"><button type="button" id="system-resync" class="button secondary">Resync now</button></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="health-grid"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Storage</p><h2>Disk usage</h2></div></div><div id="system-storage" class="health-grid"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Build</p><h2>Version</h2></div></div><div id="system-version" class="muted"></div></div>',
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Gateway</p><h2>Reload & restart</h2></div></div><p class="muted">Reloading re-applies the current configuration to Caddy with no downtime. Restarting stops and restarts the whole application \u2014 only available when a restart policy is set on the container.</p><div class="row-actions"><button type="button" id="system-reload" class="button secondary">Reload gateway config</button><button type="button" id="system-restart" class="button secondary danger-text" disabled>Restart application</button></div><p id="system-restart-status" class="muted"></p></div>',
].join("");
panel.querySelector("#system-resync").addEventListener("click", async event => {
const button = event.currentTarget; button.disabled = true; const original = button.textContent; button.textContent = "Resyncing\u2026";
try { await api("/api/gateway/resync", { method: "POST" }); toast("Gateway configuration re-synced."); await refresh(); }
catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; button.textContent = original; }
});
panel.querySelector("#system-reload").addEventListener("click", async event => {
const button = event.currentTarget; button.disabled = true; const original = button.textContent; button.textContent = "Reloading\u2026";
try { await api("/api/system/reload", { method: "POST" }); toast("Gateway configuration reloaded."); await refresh(); }
catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; button.textContent = original; }
});
panel.querySelector("#system-restart").addEventListener("click", async event => {
if (!await themedConfirm("Restart Site Gateway?", "The application will stop and restart. This takes a few seconds and briefly interrupts hosted sites and the dashboard.", "Restart")) return;
const button = event.currentTarget; button.disabled = true; button.textContent = "Restarting\u2026";
try { await api("/api/system/restart", { method: "POST" }); toast("Restarting \u2014 this dashboard will be unavailable briefly."); }
catch (error) { toast(error.message, "error"); button.disabled = false; button.textContent = "Restart application"; }
});
}
renderSystemStatus(panel);
}
async function renderSystemStatus(panel) {
panel = panel || document.querySelector('[data-admin-panel="system"]');
if (!panel) return;
const security = document.querySelector("#system-security"), storage = document.querySelector("#system-storage"),
version = document.querySelector("#system-version"), jobs = document.querySelector("#system-jobs"),
syncStatus = document.querySelector("#system-sync-status"), restartButton = document.querySelector("#system-restart"),
restartStatus = document.querySelector("#system-restart-status");
if (jobs) jobs.innerHTML = (state.dashboard?.system?.jobs || []).map(job => {
const status = job.enabled ? `Active \u00b7 ${extendedEscape(job.schedule)}` : "Disabled";
const lastRun = job.lastRunAt ? `Last run ${extendedEscape(formatTime(job.lastRunAt))}${job.lastStatus && job.lastStatus !== "ok" ? ` \u00b7 ${extendedEscape(job.lastStatus)}` : ""}` : "No run recorded yet";
return `<div class="health-tile"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${extendedEscape(job.name)}</strong><small>${status} \u00b7 ${lastRun}</small></span></div>`;
}).join("") || '<p class="quiet-state">No scheduled jobs reported.</p>';
const envStatus = document.querySelector("#system-env-status");
if (envStatus) {
const encryptionAvailable = Boolean(state.config?.backup?.encryptionAvailable);
envStatus.innerHTML = `<div class="health-tile"><span class="status-dot ${encryptionAvailable ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>BACKUP_PASSWORD</strong><small>${encryptionAvailable ? "Configured \u2014 scheduled backups can be encrypted." : "Not set \u2014 configure it in the container\u2019s environment to enable encrypted scheduled backups."}</small></span></div>`;
}
if (syncStatus) { const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift"); syncStatus.textContent = drift ? "Configuration drift detected \u2014 the running gateway no longer matches the last known-good configuration." : `Gateway configuration is in sync. Last reload: ${state.dashboard?.gateway?.lastReload ? formatTime(state.dashboard.gateway.lastReload) : "unknown"}.`; syncStatus.className = drift ? "muted status-warning" : "muted"; }
if (version) version.innerHTML = `Site Gateway v${extendedEscape(state.config?.version || "unknown")}<br>Access this dashboard at: <code>${extendedEscape(location.origin)}</code><br>Data directory: <code>${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")}</code> &middot; Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
try {
const [sec, store, policy] = await Promise.all([
api("/api/system/security"),
api("/api/system/storage"),
api("/api/system/restart-policy"),
]);
if (security) security.innerHTML = [
{ ok: !sec.adminPasswordIsDefault, label: "ADMIN_PASSWORD", detail: sec.adminPasswordIsDefault ? "Still using the built-in default \u2014 set this before exposing the dashboard." : "Configured." },
{ ok: !sec.sessionSecretIsDefault, label: "SESSION_SECRET", detail: sec.sessionSecretIsDefault ? "Not set \u2014 sessions are keyed off the admin credentials instead of an independent secret." : "Configured." },
{ ok: sec.acmeEmailConfigured, label: "ACME_EMAIL", detail: sec.acmeEmailConfigured ? "Configured." : "Not set \u2014 certificate issuance will proceed without a registration contact." },
].map(row => `<div class="health-tile"><span class="status-dot ${row.ok ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${row.label}</strong><small>${row.detail}</small></span></div>`).join("");
if (storage) {
const rows = Object.entries(store.breakdown || {}).map(([key, bytes]) => `<div class="health-tile"><span class="status-dot running"></span><span class="health-tile-copy"><strong>${key[0].toUpperCase()}${key.slice(1)}</strong><small>${formatBytes(bytes)}</small></span></div>`).join("");
const capacity = store.capacity ? `<div class="health-tile"><span class="status-dot ${store.capacity.availableBytes / store.capacity.totalBytes > 0.1 ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>Disk</strong><small>${formatBytes(store.capacity.availableBytes)} free of ${formatBytes(store.capacity.totalBytes)}</small></span></div>` : "";
storage.innerHTML = rows + capacity || '<p class="quiet-state">Storage usage unavailable.</p>';
}
if (restartButton) { restartButton.disabled = !policy.restartAvailable; if (restartStatus) restartStatus.textContent = policy.reason || (policy.policyName ? `Restart policy: ${policy.policyName}.` : ""); }
} catch { /* Status widgets keep their last-known values if a refresh call fails. */ }
}
// --- Wire the new panels into the shared refresh entry point ----------------------------------
const baseRenderExtendedViews = window.renderExtendedViews;
window.renderExtendedViews = function () {
baseRenderExtendedViews();
renderApiTokensPanel();
renderSystemPanel();
renderDockerPanel();
decorateContainerPickers();
renderBackupHistory();
+11 -9
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);
+26 -2
View File
@@ -109,7 +109,7 @@ dialog::backdrop{background:rgba(var(--backdrop-rgb),.76);backdrop-filter:blur(5
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:var(--space-5)}
#account-password-form .error{margin:0;min-height:.3em}
#account-password-form .dialog-actions{margin-top:var(--space-2)}
.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:var(--toast-bg);color:var(--toast-text);padding:11px var(--space-4);border-radius:var(--radius-sm);box-shadow:var(--shadow);transition:.2s;pointer-events:none}
.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:var(--toast-bg);color:var(--toast-text);padding:11px var(--space-4);border-radius:var(--radius-sm);box-shadow:var(--shadow);transition:.2s;pointer-events:none;z-index:2147483647}
.toast.show{opacity:1;transform:translate(-50%,0)}
.toast.toast-error{background:var(--toast-error-bg);color:var(--toast-error-text)}
.update-banner{position:fixed;left:50%;bottom:30px;transform:translate(-50%,0);display:flex;align-items:center;gap:var(--space-4);background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-md);padding:14px var(--space-4) 14px 20px;box-shadow:var(--shadow);z-index:5;color:var(--text);font-size:.88rem}
@@ -218,7 +218,7 @@ header{align-items:flex-end}
.live-dot.checking{background:var(--warning);animation-duration:.9s}
@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(var(--green-rgb),.5)}70%{box-shadow:0 0 0 6px rgba(var(--green-rgb),0)}100%{box-shadow:0 0 0 0 rgba(var(--green-rgb),0)}}
.system-panel{position:relative;overflow:hidden}
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--blue);opacity:.85}
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--green);opacity:.85}
.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}
.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
.system-grid dt{color:var(--muted);font-size:.72rem}
@@ -234,6 +234,8 @@ header{align-items:flex-end}
.attention-tile{display:flex;align-items:center;gap:var(--space-3);min-width:0;padding:13px 14px;border:1px solid var(--line);border-left:3px solid var(--danger);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
.issue-link.attention-tile{cursor:pointer;transition:.2s}
.issue-link.attention-tile:hover{background:rgba(var(--danger-rgb),.08);border-color:var(--border-hover-alt)}
.drift-tile .attention-copy{flex:1}
.drift-tile .button{padding:var(--space-2) 12px;font-size:.72rem;flex-shrink:0}
.attention-copy,.activity-copy{display:grid;gap:3px;min-width:0}
.attention-copy strong,.activity-copy strong{font-size:var(--font-size-md)}
.attention-copy small,.activity-copy small{color:var(--muted);font-size:.72rem}
@@ -280,6 +282,7 @@ header{align-items:flex-end}
.row-highlight{background:rgba(var(--green-rgb),.08)}
.user-head-actions{display:flex;align-items:center;gap:10px}
[data-admin-panel="backups"]>.dashboard-panel{margin-top:var(--space-5)}
[data-admin-panel="system"]>.dashboard-panel+.dashboard-panel{margin-top:18px}
.user-role-select{appearance:none!important;-webkit-appearance:none!important;height:44px;min-height:44px;width:100%;box-sizing:border-box;padding:0 42px 0 var(--space-3);line-height:42px;border:1px solid var(--line);border-radius:var(--radius-sm);background-color:var(--panel);color:var(--text);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23f4f7fb' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;background-size:16px}
@media(max-width:760px){.data-row{grid-template-columns:auto 1fr}.data-row>div:nth-of-type(n+2){grid-column:2}.feature-summary{gap:var(--space-2)}.feature-summary>div{padding:13px}.log-toolbar{align-items:stretch;flex-direction:column}.log-toolbar label{min-width:0}}
/* Corrective layout pass: keep controls, indicators, and card footers visually consistent. */
@@ -707,6 +710,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
.inline-status{grid-column:1/-1;margin:0;color:var(--green);font-size:.8rem;font-weight:700}
.inline-status.status-success{color:var(--green)}
.inline-status.status-warning{color:var(--warning)}
.muted.status-warning{color:var(--warning)}
.encryption-grid{align-items:start}
.encryption-toggle{align-items:flex-start;padding-top:28px}
.encryption-toggle small{display:block;margin-top:5px;color:var(--muted);font-size:.72rem;line-height:1.4}
@@ -769,6 +773,26 @@ label.check-control:has(input[name="upstreamTlsInsecure"]){position:relative;hei
label.check-control:has(input[name="upstreamTlsInsecure"]) span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
label.check-control:has(input[name="upstreamTlsInsecure"]) small{position:absolute;left:0;top:calc(100% + 7px);width:100%;padding:0!important;white-space:normal}
.system-integrations:not(:empty){margin-top:var(--space-3)}
.system-integrations .check-control{margin:0}
#system-restart-status{margin-top:var(--space-3)}
/* Custom-drawn select (Task #20): native <select> popups can't be reliably themed dark across
browsers, so this replaces the popup only -- the native select stays for value/form/event
compatibility, positioned invisibly beneath the trigger. */
.custom-select{position:relative;display:block;width:100%;margin-top:7px}
.custom-select select{position:absolute;inset:0;opacity:0;pointer-events:none;margin:0;width:100%;height:100%}
.custom-select-trigger{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);width:100%;border:1px solid var(--line);border-radius:var(--radius-sm);padding:var(--space-3);background:var(--field-bg);color:var(--text);font:inherit;text-align:left;cursor:pointer}
.custom-select-trigger::after{content:"";width:9px;height:9px;flex:0 0 auto;border-right:2px solid var(--muted);border-bottom:2px solid var(--muted);transform:rotate(45deg) translateY(-2px)}
.custom-select-trigger:focus-visible{outline:none;border-color:var(--green);box-shadow:0 0 0 3px rgba(var(--green-rgb),.1)}
.custom-select-trigger[aria-expanded="true"]::after{transform:rotate(225deg) translateY(-2px)}
.custom-select-trigger:disabled{opacity:.55;cursor:not-allowed}
.custom-select-menu{position:fixed;z-index:2147483647;max-height:min(280px,40vh);overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--panel);box-shadow:var(--shadow);padding:4px}
.custom-select-option{padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);cursor:pointer;color:var(--text);font-size:.85rem}
.custom-select-option:hover,.custom-select-option.is-highlighted{background:rgba(var(--panel-rgb),.6);background:var(--field-bg)}
.custom-select-option.is-selected{color:var(--green)}
.custom-select-option.is-disabled{color:var(--muted);cursor:not-allowed}
/* Shared diagnostic lists: Certificates, Access Logs, Gateway Events, Audit */
select{appearance:none!important;-webkit-appearance:none!important;background-repeat:no-repeat!important;background-position:right 14px center!important;background-size:16px!important}
#certificate-list,#readiness-list{max-height:min(52vh,620px);overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
+105 -23
View File
@@ -638,6 +638,10 @@ async function syncCaddy() {
await execFileAsync("caddy", ["reload", "--config", caddyfilePath, "--adapter", "caddyfile"]);
gatewayError = null;
lastGatewayReload = new Date().toISOString();
try {
const liveAfterReload = await caddyAdminRequest({ method: "GET", path: "/config/" });
if (liveAfterReload.status === 200) lastKnownGoodCaddyConfig = JSON.parse(liveAfterReload.body);
} catch (error) { console.warn("Could not capture post-reload config baseline:", error.message); }
} catch (error) {
const rejectedReason = error.stderr || error.message;
let rollbackSucceeded = false;
@@ -659,6 +663,9 @@ async function syncCaddy() {
let configDrift = { checkedAt: null, drift: false, detail: null };
let lastUpstreamCheckAt = null;
let lastAccessLogImportAt = null;
let lastKnownGoodCaddyConfig = null;
function caddyAdminRequest(options, body) {
return new Promise((resolve, reject) => {
const request = http.request({ host: "127.0.0.1", port: 2019, timeout: 5000, ...options }, response => {
@@ -672,22 +679,28 @@ function caddyAdminRequest(options, body) {
request.end();
});
}
function stableStringify(value) {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
async function checkConfigDrift() {
const wasDrifting = configDrift.drift;
try {
const caddyfileContent = await fsp.readFile(caddyfilePath, "utf8").catch(() => null);
if (!caddyfileContent) return;
const [adapted, live] = await Promise.all([
caddyAdminRequest({ method: "POST", path: "/adapt", headers: { "Content-Type": "text/caddyfile" } }, caddyfileContent),
caddyAdminRequest({ method: "GET", path: "/config/" })
]);
if (adapted.status !== 200 || live.status !== 200) return;
const adaptedParsed = JSON.parse(adapted.body);
const adaptedConfig = adaptedParsed && adaptedParsed.config !== undefined ? adaptedParsed.config : adaptedParsed;
const live = await caddyAdminRequest({ method: "GET", path: "/config/" });
if (live.status !== 200) return;
const liveConfig = JSON.parse(live.body);
const drift = JSON.stringify(adaptedConfig) !== JSON.stringify(liveConfig);
configDrift = { checkedAt: new Date().toISOString(), drift, detail: drift ? "Caddy\u2019s live configuration no longer matches the saved configuration." : null };
if (!lastKnownGoodCaddyConfig) {
lastKnownGoodCaddyConfig = liveConfig;
configDrift = { checkedAt: new Date().toISOString(), drift: false, detail: null };
return;
}
const drift = stableStringify(lastKnownGoodCaddyConfig) !== stableStringify(liveConfig);
configDrift = { checkedAt: new Date().toISOString(), drift, detail: drift ? "Caddy\u2019s live configuration no longer matches the last known-good configuration." : null };
if (drift && !wasDrifting) recordActivity("Configuration drift detected: Caddy\u2019s live configuration no longer matches the last known-good configuration.", "warning");
} catch (error) {
// Caddy admin API unreachable, or transient error: don\u2019t flag drift on a check we couldn\u2019t complete.
configDrift = { ...configDrift, checkedAt: new Date().toISOString() };
}
}
@@ -964,9 +977,9 @@ async function dashboardSnapshot() {
if (httpProbe.status === "error") attention.push({ kind: "http", name: "HTTP · Port 80", message: "Port 80 is not accepting connections inside the container." });
if (httpsProbe.status === "error") attention.push({ kind: "https", name: "HTTPS · Port 443", message: "TLS domains are enabled but port 443 is not accepting connections." });
if (!storageWritable) attention.push({ kind: "storage", name: "Persistent storage", message: "The data directory is not readable and writable." });
for (const site of hosted.filter(item => item.status === "error")) attention.push({ kind: "hosted", name: site.name, message: `Hosted site is not responding on port ${site.port}.` });
for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention." });
for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.` });
for (const site of hosted.filter(item => item.status === "error")) attention.push({ kind: "hosted", name: site.name, message: `Hosted site is not responding on port ${site.port}.`, target: "hosted" });
for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention.", target: "proxies" });
for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.`, target: "proxies" });
for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` });
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration/defaults" });
const disk = await fsp.statfs(dataDir).catch(() => null);
@@ -1002,7 +1015,14 @@ async function dashboardSnapshot() {
publicIp: publicIpState.address,
publicIpCheckedAt: publicIpState.checkedAt,
publicIpError: publicIpState.error,
jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }, { name: "Public IP check", enabled: true, schedule: "60m" }]
jobs: [
{ name: "Upstream checks", enabled: true, schedule: "60s", lastRunAt: lastUpstreamCheckAt },
{ name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off", lastRunAt: settings.backups?.lastRunAt || null, lastStatus: settings.backups?.lastStatus || null },
{ name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off", lastRunAt: settings.logsRetention?.lastRunAt || null },
{ name: "Access-log import", enabled: true, schedule: "30s", lastRunAt: lastAccessLogImportAt },
{ name: "Public IP check", enabled: true, schedule: "60m", lastRunAt: publicIpState.checkedAt || null },
{ name: "Configuration drift check", enabled: true, schedule: "10m", lastRunAt: configDrift.checkedAt || null },
]
},
activity: recentActivity
};
@@ -1331,11 +1351,11 @@ app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.get(["/", "/index.html"], (req, res) => {
const html = fs.readFileSync(path.join(publicDir, "index.html"), "utf8")
.replace(/\/(app|features)\.js\?v=[^"']+/g, `/$1.js?v=${appVersion}`)
.replace(/\/(app|features|select-enhance)\.js\?v=[^"']+/g, `/$1.js?v=${appVersion}`)
.replace(/\/styles\.css\?v=[^"']+/g, `/styles.css?v=${appVersion}`);
res.type("html").send(html);
});
app.use(express.static(publicDir));
app.use(express.static(publicDir, { setHeaders: (res, filePath) => { if (/\/(app|features)\.js$/.test(filePath)) res.setHeader("Cache-Control", "no-cache"); } }));
app.use("/site-icons", express.static(iconsDir, { immutable: true, maxAge: "30d", setHeaders: res => res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") }));
@@ -1559,10 +1579,70 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
// --- Config, Users, Audit log, Groups, Access List <-> Group assignment --------------------------------------
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) }, docker: { socketMounted: dockerSocketMounted, enabled: dockerSocketMounted && settings.dockerIntegration?.enabled === true } }));
// --- System tab: storage usage, restart-policy check, and self-restart -----------------------------------
app.get("/api/system/storage", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try {
const breakdown = {};
for (const [key, dir] of Object.entries({ sites: sitesDir, backups: backupsDir, certificates: certificatesRoot, logs: logsDir, database: path.join(dataDir, "database") })) {
breakdown[key] = await directorySize(dir);
}
let capacity = null;
try {
const stats = await fsp.statfs(dataDir);
capacity = { totalBytes: stats.blocks * stats.bsize, freeBytes: stats.bfree * stats.bsize, availableBytes: stats.bavail * stats.bsize };
} catch { /* statfs isn't available on every platform/Node build -- degrade to breakdown-only. */ }
res.json({ dataDir, breakdown, usedBytes: Object.values(breakdown).reduce((sum, value) => sum + value, 0), capacity });
} catch (error) { next(error); }
});
// Inspects this container's own restart policy via the Docker Engine API, reusing the same
// mounted-socket + self-identification (process.env.HOSTNAME) pattern as the container picker.
async function ownRestartPolicy() {
if (!dockerSocketMounted) return { checked: false, policyName: null, restartAvailable: false, reason: "Restart availability cant be verified — mount /var/run/docker.sock into this container so Site Gateway can confirm it will come back up before offering a restart." };
const ownId = String(process.env.HOSTNAME || "").trim();
if (!ownId) return { checked: false, policyName: null, restartAvailable: false, reason: "Could not determine this container's own ID." };
try {
const own = await dockerRequest(`/containers/${encodeURIComponent(ownId)}/json`);
const policyName = own?.HostConfig?.RestartPolicy?.Name || "no";
const restartAvailable = ["always", "unless-stopped", "on-failure"].includes(policyName);
return { checked: true, policyName, restartAvailable, reason: restartAvailable ? null : `Restart policy is "${policyName}" — set it to "unless-stopped" (or similar) in your container config to enable restarting from here.` };
} catch (error) { return { checked: false, policyName: null, restartAvailable: false, reason: `Could not read the container's restart policy: ${error.message}` }; }
}
app.get("/api/system/restart-policy", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try { res.json(await ownRestartPolicy()); } catch (error) { next(error); }
});
app.get("/api/system/security", (req, res) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
res.json({
adminPasswordIsDefault: process.env.ADMIN_PASSWORD === undefined,
sessionSecretIsDefault: process.env.SESSION_SECRET === undefined,
acmeEmailConfigured: Boolean(String(process.env.ACME_EMAIL || "").trim()),
});
});
app.post("/api/system/restart", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try {
const policy = await ownRestartPolicy();
if (!policy.restartAvailable) return res.status(409).json({ error: policy.reason || "Restarting is not available." });
recordActivity("Administrator restarted Site Gateway.", "warning");
res.json({ ok: true });
setTimeout(() => process.exit(0), 250);
} catch (error) { next(error); }
});
app.post("/api/system/reload", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try { await syncCaddy(); recordActivity("Administrator reloaded the gateway configuration."); res.json({ ok: true, lastGatewayReload }); } catch (error) { next(error); }
});
app.post("/api/gateway/resync", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try {
await syncCaddy();
try {
const liveAfterResync = await caddyAdminRequest({ method: "GET", path: "/config/" });
if (liveAfterResync.status === 200) lastKnownGoodCaddyConfig = JSON.parse(liveAfterResync.body);
} catch (error) { console.warn("Could not capture post-resync config baseline:", error.message); }
configDrift = { checkedAt: new Date().toISOString(), drift: false, detail: null };
recordActivity(`Gateway configuration re-synced by \u201c${req.user.username}\u201d.`);
res.json({ ok: true });
@@ -2223,7 +2303,9 @@ app.patch("/api/settings", async (req, res, next) => {
const days = key => Math.min(Math.max(Number(value[key]) || 30, 7), 3650);
settings.logsRetention = { ...settings.logsRetention, accessDays: days("accessDays"), activityDays: days("activityDays"), auditDays: days("auditDays"), certificateDays: days("certificateDays"), securityDays: days("securityDays"), pruningEnabled: value.pruningEnabled === true };
}
await syncCaddy(); await saveSettings(); recordActivity("Administration settings updated."); res.json({ ...settings, backupDirectory: backupsDir });
if (req.body.defaultSite) await syncCaddy();
await saveSettings();
recordActivity("Administration settings updated."); res.json({ ...settings, backupDirectory: backupsDir });
} catch (error) { next(error); }
});
app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } });
@@ -2290,8 +2372,8 @@ app.listen(adminPort, "0.0.0.0", () => {
if (adminPassword === "change-this-password") console.warn("WARNING: Change ADMIN_PASSWORD before exposing the dashboard.");
});
setTimeout(() => checkAllProxies().catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref();
setInterval(() => checkAllProxies().catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
setTimeout(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref();
setInterval(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
setTimeout(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10000).unref();
setInterval(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10 * 60000).unref();
@@ -2315,8 +2397,8 @@ setTimeout(() => runScheduledBackup().catch(error => console.warn("Scheduled bac
setInterval(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 15 * 60000).unref();
async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } }
setInterval(() => runScheduledPruning(), 15 * 60000).unref();
setTimeout(() => importAccessLogsToSqlite(), 8000).unref();
setInterval(() => importAccessLogsToSqlite(), 30000).unref();
setTimeout(() => importAccessLogsToSqlite().then(() => { lastAccessLogImportAt = new Date().toISOString(); }).catch(error => console.warn("Access-log import failed:", error.message)), 8000).unref();
setInterval(() => importAccessLogsToSqlite().then(() => { lastAccessLogImportAt = new Date().toISOString(); }).catch(error => console.warn("Access-log import failed:", error.message)), 30000).unref();
async function checkPublicIp() {
try {