Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17f1624c7c | |||
| e705e814af | |||
| f3294e0ada | |||
| 00f990f8b0 | |||
| 2577493225 | |||
| 23784accde | |||
| cb94e5c63c | |||
| 157c680e58 | |||
| b8dca3e397 | |||
| 17c059e215 | |||
| f33fa99686 | |||
| 9c1426d276 |
@@ -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.2-62E6A7">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.12-62E6A7">
|
||||
</p>
|
||||
<p>
|
||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||
|
||||
+39
@@ -34,6 +34,25 @@
|
||||
- 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 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.
|
||||
@@ -83,6 +102,8 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
|
||||
- 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 host’s 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
|
||||
|
||||
- Current icon and wordmark (v0.11.99) used consistently across the login screen, sidebar, themed default pages, and this README.
|
||||
@@ -111,3 +132,21 @@ 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.
|
||||
|
||||
`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.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.15.2",
|
||||
"version": "0.16.12",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
|
||||
+104
-15
@@ -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>';
|
||||
@@ -429,7 +429,6 @@ async function renderBackupHistory() {
|
||||
panel.append(section);
|
||||
}
|
||||
const list = section.querySelector("#backup-history-list");
|
||||
if (panel.classList.contains("hidden")) return;
|
||||
try {
|
||||
const events = await api("/api/backups/history");
|
||||
list.innerHTML = events.length ? events.map(item => {
|
||||
@@ -446,16 +445,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."); }
|
||||
@@ -463,13 +470,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.
|
||||
@@ -524,11 +532,92 @@ 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">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>Sync & control</h2></div></div><p id="system-sync-status" class="muted"></p><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-resync" class="button secondary">Resync now</button><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> · 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();
|
||||
|
||||
+10
-8
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
@@ -282,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. */
|
||||
@@ -597,6 +598,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
|
||||
|
||||
/* Backup & restore form layout */
|
||||
.settings-form .form-section{grid-column:1/-1;padding:var(--space-1) 0 20px;border-bottom:1px solid var(--line)}
|
||||
#default-site-help{grid-column:1/-1;margin:0 0 var(--space-4)}
|
||||
.settings-form .form-section+.form-section{padding-top:20px}
|
||||
.settings-form .form-section:last-of-type{border-bottom:0}
|
||||
.settings-form .form-section .eyebrow{margin-bottom:var(--space-3)}
|
||||
@@ -709,6 +711,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}
|
||||
@@ -771,6 +774,27 @@ 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-sync-status+p{margin-top:var(--space-3)}
|
||||
#system-restart-status{margin-top:var(--space-3)}
|
||||
|
||||
/* Custom-drawn select (Task #20): native <select> popups can't be reliably themed dark across
|
||||
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)}
|
||||
|
||||
+90
-10
@@ -663,6 +663,8 @@ 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) => {
|
||||
@@ -1013,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" }, { name: "Configuration drift check", enabled: true, schedule: "10m" }]
|
||||
jobs: [
|
||||
{ name: "Upstream checks", enabled: true, schedule: "60s", lastRunAt: lastUpstreamCheckAt },
|
||||
{ name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off", lastRunAt: settings.backups?.lastRunAt || null, lastStatus: settings.backups?.lastStatus || null },
|
||||
{ name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off", lastRunAt: settings.logsRetention?.lastRunAt || null },
|
||||
{ name: "Access-log import", enabled: true, schedule: "30s", lastRunAt: lastAccessLogImportAt },
|
||||
{ name: "Public IP check", enabled: true, schedule: "60m", lastRunAt: publicIpState.checkedAt || null },
|
||||
{ name: "Configuration drift check", enabled: true, schedule: "10m", lastRunAt: configDrift.checkedAt || null },
|
||||
]
|
||||
},
|
||||
activity: recentActivity
|
||||
};
|
||||
@@ -1342,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'") }));
|
||||
|
||||
|
||||
@@ -1570,6 +1579,62 @@ 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 can’t 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 {
|
||||
@@ -2238,10 +2303,24 @@ 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); } });
|
||||
// Pre-prune snapshots (pre-prune-*.sqlite) are safety copies taken before every log prune, scheduled or
|
||||
// manual. They are not real backups: they never appear in the Backup & Restore list and can't be
|
||||
// deleted from there (only .sgbackup files can). Without cleanup they accumulate forever and silently
|
||||
// inflate the Storage breakdown on the System tab. Keep only the most recent few after each prune.
|
||||
async function cleanupOldPruneSnapshots(keep = 3) {
|
||||
try {
|
||||
const names = (await fsp.readdir(backupsDir)).filter(name => name.startsWith("pre-prune-") && name.endsWith(".sqlite"));
|
||||
const withStats = await Promise.all(names.map(async name => ({ name, mtime: (await fsp.stat(path.join(backupsDir, name))).mtimeMs })));
|
||||
withStats.sort((a, b) => b.mtime - a.mtime);
|
||||
for (const item of withStats.slice(keep)) await fsp.rm(path.join(backupsDir, item.name), { force: true });
|
||||
} catch (error) { console.warn("Could not clean up old pre-prune snapshots:", error.message); }
|
||||
}
|
||||
app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); await cleanupOldPruneSnapshots(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } });
|
||||
app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } });
|
||||
app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } });
|
||||
app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.defaultSite = { mode:"themed404", redirectUrl:"", redirectCode:302, preservePath:true, title:"Route not found", message:"The gateway is responding, but this address has not been configured.", customHtml:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"complete", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } });
|
||||
@@ -2305,8 +2384,9 @@ 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();
|
||||
setTimeout(() => cleanupOldPruneSnapshots().catch(error => console.warn("Startup pre-prune snapshot cleanup failed:", error.message)), 2000).unref();
|
||||
setInterval(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
|
||||
setTimeout(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10000).unref();
|
||||
setInterval(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10 * 60000).unref();
|
||||
|
||||
@@ -2328,10 +2408,10 @@ async function runScheduledBackup() {
|
||||
}
|
||||
setTimeout(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 5000).unref();
|
||||
setInterval(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 15 * 60000).unref();
|
||||
async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } }
|
||||
async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); await cleanupOldPruneSnapshots(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } }
|
||||
setInterval(() => runScheduledPruning(), 15 * 60000).unref();
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user