Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6821a30b65 | |||
| 4e4823b0f7 | |||
| 747c776a84 | |||
| 5b5ba9b3be | |||
| 53b47b77a6 | |||
| 146b7f97ae | |||
| 238c5aedc2 |
@@ -8,3 +8,10 @@ ACME_EMAIL=you@example.com
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
BACKUP_PASSWORD=
|
||||
|
||||
# Optional resource-panel tuning (see compose.release.yaml). Leave commented
|
||||
# to run unlimited / compare Disk against the whole volume.
|
||||
# DATA_DIR_LIMIT_GB=30
|
||||
# MEM_LIMIT=2g
|
||||
# CPU_LIMIT=2
|
||||
# CPUSET=0,1
|
||||
|
||||
+14
@@ -252,3 +252,17 @@ Roughly in priority order:
|
||||
`v0.16.63` converts the Backup history panel (Administration -> Backup & Restore) from a stacked-card timeline to the same pinned-header table used everywhere else records are logged -- Access Logs and Gateway Events. It was the one remaining place in the app presenting a log-like record as a list of `.activity-tile` cards (the same component the Dashboard's Recent Activity feed uses) instead of a table, and looked inconsistent sitting next to those two. The table has four columns -- Time, Action (Backup/Restored/Deleted/Imported), Type (Complete/Configuration/Safety), and Detail (size, a note that a safety backup was taken first, or the failure reason) -- reusing the exact `performance-table`/`event-table` CSS and column widths Gateway Events already established, plus the same green/red `status-dot` convention for success vs. failure. No backend or data changes; `/api/backups/history` already returned everything the new columns needed.
|
||||
|
||||
`v0.16.64` fixes two issues reported right after v0.16.63 shipped the new Backup history table. First, creating, deleting, or importing a backup updated the "Stored backups" list immediately but left Backup history showing stale data until something else triggered a full page refresh -- each of those three handlers called `renderBackups()` afterward but never `renderBackupHistory()`; only Restore happened to update it, incidentally, because it runs the app's full `refresh()` cycle afterward for unrelated reasons. All three now also call `renderBackupHistory()` directly, so every action that writes a history entry reflects it immediately, matching what Restore already did by accident. Second, the table looked lopsided -- all its content bunched on the left with a large empty gap on the right -- because it reused Gateway Events' column widths (`190px/110px/140px/auto`), sized for a Message column that's usually full of long text; Backup history's last column is normally a short phrase like "71 MB", so the table stretched to the full panel width with nothing to fill it. The table now has its own column widths (26%/18%/18%/auto) inside a capped max-width (880px) instead of stretching edge-to-edge, so it reads as one balanced block rather than left-aligned content trailing into empty space.
|
||||
|
||||
`v0.16.65` fixes three issues reported against the Stored backups card and the new Backup history table right after v0.16.63/v0.16.64. First, the "Configuration only" type chip used the same background color as the surrounding panel (`var(--panel2)` on `var(--panel2)`), making it functionally invisible next to the green "Complete" chip -- it now uses the app's purple accent, tinted the same way every other colored chip already is. Second, the "Site Gateway x.x.x" version line under each chip sat flush with the column's left edge while the chip's own text started a few pixels in from its padding, reading as slightly misaligned -- a `.chip+small{margin-left:var(--space-2)}` rule now lines the two up. Third, and most visible: Backup history's width cap from v0.16.64 (880px) was the wrong fix for the column-spacing complaint -- capping the table made it the one log-style table in the app that doesn't span the panel's full width like Access Logs and Gateway Events do, which is a bigger inconsistency than the spacing problem it was meant to solve. The cap is removed; the wider percentage-based columns introduced alongside it (26%/18%/18%/auto) stay, so the table is full-width again but its columns are no longer crowded into the first ~440px the way the borrowed Gateway Events pixel-widths had them.
|
||||
|
||||
`v0.16.66` replaces the generic "X updated" activity message every edit route wrote to Gateway Events -- Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts each logged one identical line regardless of what was actually changed in the save, so a rename, a domain change, a TLS toggle, and a health-check edit were all indistinguishable in the log. Each of the four `PATCH` routes now snapshots the relevant fields before applying the request body, diffs them against the saved result, and writes one comma-joined line naming exactly what changed in that save -- e.g. `Proxy host "Plex" updated — renamed from "Plex Media", target changed to 192.168.1.20:32400, TLS set to automatic.` Multiple fields changed in a single save produce one combined line, not one line per field; saving a form with no actual changes (an Edit dialog opened and immediately saved) now writes nothing at all, instead of the previous generic entry firing unconditionally. Primary fields (name, domain/aliases, target, TLS, HSTS, Access List, health-check on/off, and per-type fields like redirect code/path-preservation or streaming port/protocol) are called out individually; the long tail of advanced settings (custom headers, custom Caddy config, load-balancing upstreams/policy, upstream TLS overrides, and the fine-grained health-check parameters) are bucketed into a single "advanced settings updated" line to keep the summary readable rather than enumerating every possible field.
|
||||
|
||||
`v0.16.67` converts the Administration -> Users -> Audit log tab from a card-row list (`.event-row` divs with a checkmark/bang `.activity-mark`) to the same pinned-header table every other log-type page now uses -- Gateway Events, Access Logs, and Backup history (v0.16.63). It was the last page still on the older list pattern, noticed immediately after v0.16.66 added several new distinct audit entries and made the mismatch obvious side by side with Gateway Events. The table has four columns -- Time, User, Result, Action -- reusing the exact `performance-table`/`event-table` CSS and the standard green/red `status-dot` convention in place of the old custom `.activity-mark` dot. No backend or data changes; `/api/audit` already returned everything the columns needed. The now-orphaned `#audit-list` div styling (border, background, max-height, the bespoke activity-mark colors) is removed along with it, since the table's own wrapper classes already provide the equivalent scrolling/sticky-header container.
|
||||
|
||||
`v0.16.68` is a quick follow-on to v0.16.67: the Audit log's new table had no surrounding card, so it sat flush against the tab content instead of appearing inside the same bordered, elevated "tile" every other tab-hosted table uses -- Backup history's `dashboard-panel backup-history-section` wrapper on the Backups tab being the closest match. The Audit log's table markup is now wrapped in an equivalent `dashboard-panel audit-log-section` with an "Audit log" eyebrow above the existing heading, matching Backup history's structure exactly (search/filter row and table unchanged). Purely a wrapper/markup change -- no new CSS, no data or behavior changes.
|
||||
|
||||
`v0.16.69` closes a gap flagged after reviewing the ZimaOS compose file: `compose.yaml`, `compose.release.yaml`, and `compose.zimaos.yaml` all documented `DATA_DIR_LIMIT_GB` and the `mem_limit`/`cpus`/`cpuset` resource-panel options in README/install.html but never actually carried them as commented-optional entries the way the marketing site's install guide showed -- someone copying a real compose file instead of the docs page got none of that guidance. All three now include the same commented-out block (env-var style in the two static compose files, `${VAR}`-substituted and wired through `.env.example` in `compose.release.yaml`). This also folds in the marketing site's `install.html`, which had drifted out of sync with an already-updated draft and was missing the same options on the user's machine -- resynced so the published guide matches what ships in the repo.
|
||||
|
||||
`v0.16.70` fixes the Access Logs table (Administration > Logs, "Access requests"), the last page still on its own pre-unification CSS: `.log-table` pinned the Status and Duration columns with `position:sticky` and drew a divider `box-shadow` on each -- a horizontal-scroll affordance none of the other log-type pages use -- and left the Request column at a fixed width instead of stretching to fill the panel, so wide viewports showed an empty gap past Duration. Both are removed: the two trailing columns are back in normal table flow, and Request now takes `width:auto` to absorb the remaining space, matching how Gateway Events, Backup history, and the Audit log already size their last column. Purely a CSS change -- no markup, data, or behavior changes.
|
||||
|
||||
`v0.16.71` fixes a Gateway Events / Audit log message that slipped past v0.16.66: the Hosted Site file-upload route ("Replace files") still wrote the old, un-prefixed `Files replaced for "<name>".` line instead of the `Hosted site "<name>" ...` convention every other Hosted Site action uses (created, enabled/disabled, deleted, and the field-level update summaries). It lived in its own route separate from the four `PATCH` handlers v0.16.66 touched, so it was missed at the time and only noticed once a user pointed out the log entry gave no way to tell which route type it belonged to. Now reads `Hosted site "<name>" files replaced.`, matching the rest.
|
||||
|
||||
@@ -20,6 +20,17 @@ services:
|
||||
PUID: ${PUID:-1000}
|
||||
PGID: ${PGID:-1000}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-}
|
||||
# Optional: display-only Disk allowance for the resource panel. Set
|
||||
# DATA_DIR_LIMIT_GB in .env to use.
|
||||
DATA_DIR_LIMIT_GB: ${DATA_DIR_LIMIT_GB:-}
|
||||
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||
# Administration > System tab's live resource panel can only show usage
|
||||
# against the whole host. Uncomment and set in .env to give CPU/Memory a
|
||||
# real, container-scoped denominator (MEM_LIMIT, e.g. "2g"; CPU_LIMIT,
|
||||
# e.g. "2"; or CPUSET, e.g. "0,1", to pin cores instead of a count).
|
||||
# mem_limit: ${MEM_LIMIT}
|
||||
# cpus: ${CPU_LIMIT}
|
||||
# cpuset: ${CPUSET}
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
|
||||
@@ -43,6 +43,20 @@ services:
|
||||
|
||||
# Optional: certificate account email, passed to Caddy's ACME client.
|
||||
ACME_EMAIL: ""
|
||||
|
||||
# Optional: display-only Disk allowance for the resource panel (e.g. a
|
||||
# smaller dedicated share) -- usage/free space still come from the real
|
||||
# volume, this just gives the panel a number to measure against.
|
||||
# DATA_DIR_LIMIT_GB: 30
|
||||
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||
# Administration > System tab's live resource panel can only show usage
|
||||
# against the whole host, which is rarely meaningful on a shared machine.
|
||||
# Setting these gives CPU/Memory a real, container-scoped denominator.
|
||||
# mem_limit: 2g
|
||||
# cpus: "2"
|
||||
# Pinning specific cores is also supported instead of (or alongside) a
|
||||
# count -- the resource panel reads whichever one Docker actually applied.
|
||||
# cpuset: "0,1"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -30,6 +30,21 @@ services:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
ACME_EMAIL: ""
|
||||
|
||||
# Optional: display-only Disk allowance for the resource panel (e.g. a
|
||||
# smaller dedicated share) -- usage/free space still come from the real
|
||||
# volume, this just gives the panel a number to measure against.
|
||||
# DATA_DIR_LIMIT_GB: 30
|
||||
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||
# Administration > System tab's live resource panel can only show usage
|
||||
# against the whole host, which is rarely meaningful on a shared ZimaOS
|
||||
# box. Setting these gives CPU/Memory a real, container-scoped
|
||||
# denominator instead.
|
||||
# mem_limit: 2g
|
||||
# cpus: "2"
|
||||
# Pinning specific cores is also supported instead of (or alongside) a
|
||||
# count -- the resource panel reads whichever one Docker actually applied.
|
||||
# cpuset: "0,1"
|
||||
ports:
|
||||
- target: 8080
|
||||
published: "8080"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.16.64",
|
||||
"version": "0.16.71",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
|
||||
@@ -140,7 +140,7 @@ function renderHealthSettings() {
|
||||
|
||||
// --- Misc admin panel decorators, called after every refresh -------------------
|
||||
function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); }
|
||||
function renderAuditPanel() { 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="audit"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="audit"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; users.parentElement.insertBefore(panel, users.nextElementSibling); } if (panel.dataset.ready) return; panel.dataset.ready = "1"; panel.innerHTML = '<div class="panel-heading"><div><h2>Configuration audit log</h2><p class="muted">A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.</p></div></div><div class="event-filters"><label>Search audit events<input id="audit-action" placeholder="Search by user, action, or target"></label><label>Result<select id="audit-status"><option value="">All results</option><option value="ok">Success</option><option value="error">Failed</option></select></label></div><div id="audit-list" class="dashboard-list event-list"><p class="quiet-state">Open this tab to load audit records.</p></div>'; const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `<div class="event-row"><span class="activity-mark ${item.status === "error" ? "bad" : ""}">${item.status === "error" ? "!" : "✓"}</span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}</small></span></div>`).join("") : '<p class="quiet-state">No matching audit records.</p>'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); }
|
||||
function renderAuditPanel() { 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="audit"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="audit"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; users.parentElement.insertBefore(panel, users.nextElementSibling); } if (panel.dataset.ready) return; panel.dataset.ready = "1"; panel.innerHTML = '<div class="dashboard-panel audit-log-section"><div class="panel-heading"><div><p class="eyebrow">Audit log</p><h2>Configuration audit log</h2><p class="muted">A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.</p></div></div><div class="event-filters"><label>Search audit events<input id="audit-action" placeholder="Search by user, action, or target"></label><label>Result<select id="audit-status"><option value="">All results</option><option value="ok">Success</option><option value="error">Failed</option></select></label></div><div class="table-wrap performance-table-wrap event-table-wrap"><table class="performance-table event-table"><thead><tr><th>Time</th><th>User</th><th>Result</th><th>Action</th></tr></thead><tbody id="audit-list"><tr><td colspan="4" class="quiet-state">Open this tab to load audit records.</td></tr></tbody></table></div></div>'; const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => { const failed = item.status === "error"; return `<tr><td>${extendedEscape(formatTime(item.created_at))}</td><td>${extendedEscape(item.actor || "System")}</td><td><span class="status-dot ${failed ? "error" : "running"}"></span>${failed ? "Failed" : "Success"}</td><td>${extendedEscape(item.action)}</td></tr>`; }).join("") : '<tr><td colspan="4" class="quiet-state">No matching audit records.</td></tr>'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); }
|
||||
function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); }
|
||||
function renderRetentionPanel() { 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="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `<div class="panel-heading"><div><h2>Logs & retention</h2><p class="muted">Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.</p></div></div><form class="settings-form retention-form"><label class="check-control"><input name="pruningEnabled" type="checkbox" ${policy.pruningEnabled ? "checked" : ""}><span>Enable automatic pruning</span></label><label>Access logs<input name="accessDays" type="number" min="7" max="3650" value="${policy.accessDays}"><small>High-volume request records.</small></label><label>Gateway activity<input name="activityDays" type="number" min="7" max="3650" value="${policy.activityDays}"><small>Operational and configuration events.</small></label><label>Audit logs<input name="auditDays" type="number" min="7" max="3650" value="${policy.auditDays}"><small>Administrative accountability records.</small></label><label>Certificate events<input name="certificateDays" type="number" min="7" max="3650" value="${policy.certificateDays}"></label><label>Security events<input name="securityDays" type="number" min="7" max="3650" value="${policy.securityDays}"></label><div class="dialog-actions"><button class="button primary">Save retention policy</button></div></form>`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); }
|
||||
// The single entry point app.js calls after every refresh() to re-render every
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<title>Site Gateway</title>
|
||||
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
|
||||
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.16.64">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.16.71">
|
||||
</head>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -451,6 +451,6 @@
|
||||
<div id="toast" class="toast" role="status"></div>
|
||||
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
|
||||
<!-- App scripts: core (app.js) then extended views/admin (features.js) -->
|
||||
<script src="/app.js?v=0.16.64" defer></script><script src="/features.js?v=0.16.64" defer></script><script src="/select-enhance.js?v=0.16.64" defer></script>
|
||||
<script src="/app.js?v=0.16.71" defer></script><script src="/features.js?v=0.16.71" defer></script><script src="/select-enhance.js?v=0.16.71" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+7
-15
@@ -262,7 +262,6 @@ table.event-table th:nth-child(3),table.event-table td:nth-child(3){width:140px;
|
||||
table.event-table th:nth-child(4),table.event-table td:nth-child(4){width:auto;white-space:normal;overflow-wrap:anywhere;text-align:left}
|
||||
@media(max-width:900px){table.event-table th:nth-child(1),table.event-table td:nth-child(1){width:150px}}
|
||||
.event-table td .status-dot{margin-right:6px;vertical-align:-1px}
|
||||
.backup-history-wrap{max-width:880px}
|
||||
table.backup-history-table th:nth-child(1),table.backup-history-table td:nth-child(1){width:26%}
|
||||
table.backup-history-table th:nth-child(2),table.backup-history-table td:nth-child(2),table.backup-history-table th:nth-child(3),table.backup-history-table td:nth-child(3){width:18%}
|
||||
table.backup-history-table th:nth-child(4),table.backup-history-table td:nth-child(4){width:auto}
|
||||
@@ -391,8 +390,9 @@ textarea:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(var(--green-r
|
||||
.redirect-card .card-footer{gap:var(--space-3)}
|
||||
.redirect-card .button{padding:7px 9px;font-size:var(--font-size-xs)}
|
||||
.chip{padding:5px var(--space-2);border-radius:var(--radius-full);background:var(--panel2);color:var(--muted);font-size:var(--font-size-xs)}
|
||||
.chip+small{margin-left:var(--space-2)}
|
||||
.chip.type-complete{background:rgba(var(--green-rgb),.15);color:var(--green)}
|
||||
.chip.type-configuration{background:var(--panel2);color:var(--muted)}
|
||||
.chip.type-configuration{background:color-mix(in srgb,var(--accent-purple) 15%,transparent);color:var(--accent-purple)}
|
||||
.chip.type-encrypted{background:rgba(var(--chip-blue-rgb),.15);color:var(--blue)}
|
||||
.chip.type-unknown{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
|
||||
.backup-summary{display:block;margin-top:6px}
|
||||
@@ -487,25 +487,21 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
|
||||
#app .upstream-diagnostics summary{padding:0;color:var(--blue);font-size:var(--font-size-xs);font-weight:700;border:0}
|
||||
#app .upstream-diagnostics .upstream-detail{margin:6px 0 0}
|
||||
|
||||
/* Log table layout & column widths */
|
||||
/* Log table layout & column widths -- matches the .event-table/.performance-table
|
||||
pattern used by Gateway Events, Backup history, and the Audit log: sticky header
|
||||
only (no pinned/divided trailing columns), and the Request column absorbs the
|
||||
remaining width instead of leaving a gap on wide panels. */
|
||||
.log-table-wrap{max-height:min(52vh,620px);overflow:auto}
|
||||
.log-table-wrap .log-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}
|
||||
.log-section-heading{margin:20px 0 10px}
|
||||
.log-section-heading h2{margin:0;font-size:1.05rem}
|
||||
.log-section-heading p{margin:var(--space-1) 0 0;font-size:.76rem}
|
||||
@media(min-width:761px){#dashboard>aside{position:sticky;top:0;height:100vh;max-height:100vh;overflow-y:auto;align-self:start}}
|
||||
.log-table th:nth-last-child(2),.log-table td:nth-last-child(2),.log-table th:last-child,.log-table td:last-child{position:sticky;background:var(--panel);z-index:2}
|
||||
.log-table th:nth-last-child(2),.log-table td:nth-last-child(2){right:104px;min-width:104px}
|
||||
.log-table th:last-child,.log-table td:last-child{right:0;min-width:104px}
|
||||
.log-table th:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}
|
||||
.log-table td:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}
|
||||
.log-table-wrap{overflow-x:hidden;overflow-y:auto}
|
||||
.log-table{table-layout:fixed}
|
||||
.log-table th:nth-child(1),.log-table td:nth-child(1){width:190px}
|
||||
.log-table th:nth-child(2),.log-table td:nth-child(2){width:220px}
|
||||
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){width:104px}
|
||||
.log-table th:nth-child(3),.log-table td:nth-child(3){white-space:normal;overflow-wrap:anywhere}
|
||||
.log-table th:last-child,.log-table td:last-child{box-shadow:-1px 0 0 var(--line)}
|
||||
.log-table th:nth-child(3),.log-table td:nth-child(3){width:auto;white-space:normal;overflow-wrap:anywhere}
|
||||
@media(max-width:900px){.log-table th:nth-child(1),.log-table td:nth-child(1){width:150px}.log-table th:nth-child(2),.log-table td:nth-child(2){width:170px}}
|
||||
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){text-align:center}
|
||||
#access-dialog .dialog-heading .close-dialog{display:none}
|
||||
@@ -826,10 +822,6 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.event-technical{margin-top:6px}
|
||||
.event-technical summary{padding:0;color:var(--blue);font-size:.7rem;cursor:pointer}
|
||||
.event-technical code{display:block;margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font-size:var(--font-size-xs)}
|
||||
#audit-list{max-height:min(52vh,620px);overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel);scrollbar-gutter:stable}
|
||||
#audit-list .event-row{align-items:center;padding:18px 20px}
|
||||
#audit-list .activity-mark{width:8px;height:8px;min-width:8px;border-radius:50%;padding:0;font-size:0;background:var(--green);box-shadow:0 0 0 4px rgba(var(--green-rgb),.1)}
|
||||
#audit-list .activity-mark.bad{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)}
|
||||
|
||||
/* Diagnostic section headings & readiness panels */
|
||||
.diagnostic-section-heading{margin:20px 0 10px;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)}
|
||||
|
||||
+63
-5
@@ -91,6 +91,21 @@ function recordActivity(message, status = "ok") {
|
||||
try { storage?.recordAudit(message, status, null, currentAuditActor); } catch (error) { console.warn("Could not record SQLite audit event:", error.message); }
|
||||
}
|
||||
|
||||
// --- Edit-route change summaries: builds one human-readable, comma-joined line describing
|
||||
// exactly what changed in a single PATCH save, instead of a generic "X updated" message
|
||||
// that looks identical whether a rename, a domain change, or a TLS toggle happened.
|
||||
function describeDomainsChange(previousDomains, nextDomains) {
|
||||
if (previousDomains.join("|") === nextDomains.join("|")) return null;
|
||||
if (!nextDomains.length) return "domain removed";
|
||||
const shown = nextDomains.slice(0, 3).join(", ");
|
||||
return `domain${nextDomains.length > 1 ? "s" : ""} changed to ${shown}${nextDomains.length > 3 ? ` (+${nextDomains.length - 3} more)` : ""}`;
|
||||
}
|
||||
function summarizeUpdate(kind, name, changes) {
|
||||
const list = changes.filter(Boolean);
|
||||
if (!list.length) return;
|
||||
recordActivity(`${kind} “${name}” updated — ${list.join(", ")}.`);
|
||||
}
|
||||
|
||||
async function directorySize(directory) {
|
||||
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
|
||||
const sizes = await Promise.all(entries.map(async entry => {
|
||||
@@ -2177,7 +2192,7 @@ app.post("/api/sites/:id/files", upload.single("files"), async (req, res, next)
|
||||
if (!req.file) return res.status(400).json({ error: "Choose a ZIP file or index.html." });
|
||||
await installUpload(site, req.file);
|
||||
await syncCaddy();
|
||||
recordActivity(`Files replaced for “${site.name}”.`);
|
||||
recordActivity(`Hosted site “${site.name}” files replaced.`);
|
||||
res.json(publicSite(site));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -2200,6 +2215,7 @@ app.patch("/api/sites/:id", async (req, res, next) => {
|
||||
const site = sites.find(item => item.id === req.params.id);
|
||||
if (!site) return res.status(404).json({ error: "Site not found." });
|
||||
const previousDomains = normalizeDomains(site.domain, site.domains);
|
||||
const before = { name: site.name, tls: site.tls, hsts: site.hsts, accessListId: site.accessListId, healthEnabled: site.healthEnabled, healthDetail: JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]) };
|
||||
const domain = normalizeDomain(req.body.domain);
|
||||
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains);
|
||||
const domainError = validateDomains(domains, site.id);
|
||||
@@ -2218,7 +2234,16 @@ app.patch("/api/sites/:id", async (req, res, next) => {
|
||||
await syncCaddy();
|
||||
await pruneOrphanedCertificates(previousDomains);
|
||||
await saveSites();
|
||||
recordActivity(`Gateway settings updated for “${site.name}”.`);
|
||||
const afterHealthDetail = JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]);
|
||||
summarizeUpdate("Hosted site", site.name, [
|
||||
before.name !== site.name ? `renamed from “${before.name}”` : null,
|
||||
describeDomainsChange(previousDomains, domains),
|
||||
before.tls !== site.tls ? `TLS set to ${site.tls}` : null,
|
||||
before.hsts !== site.hsts ? `HSTS ${site.hsts ? "enabled" : "disabled"}` : null,
|
||||
before.accessListId !== site.accessListId ? (site.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||
before.healthEnabled !== site.healthEnabled ? `health checks ${site.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
|
||||
]);
|
||||
res.json(publicSite(site));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -2257,6 +2282,7 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
|
||||
const proxy = proxies.find(item => item.id === req.params.id);
|
||||
if (!proxy) return res.status(404).json({ error: "Proxy host not found." });
|
||||
const previousDomains = normalizeDomains(proxy.domain, proxy.domains);
|
||||
const before = { name: proxy.name, target: proxy.target, tls: proxy.tls, hsts: proxy.hsts, accessListId: proxy.accessListId, healthEnabled: proxy.healthEnabled, healthDetail: JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]) };
|
||||
if (req.body.domain !== undefined) {
|
||||
const domain = normalizeDomain(req.body.domain);
|
||||
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains);
|
||||
@@ -2281,7 +2307,17 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
|
||||
await syncCaddy();
|
||||
await pruneOrphanedCertificates(previousDomains);
|
||||
await saveProxies();
|
||||
recordActivity(`Proxy host “${proxy.name}” updated.`);
|
||||
const afterHealthDetail = JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]);
|
||||
summarizeUpdate("Proxy host", proxy.name, [
|
||||
before.name !== proxy.name ? `renamed from “${before.name}”` : null,
|
||||
describeDomainsChange(previousDomains, normalizeDomains(proxy.domain, proxy.domains)),
|
||||
before.target !== proxy.target ? `target changed to ${proxy.target}` : null,
|
||||
before.tls !== proxy.tls ? `TLS set to ${proxy.tls}` : null,
|
||||
before.hsts !== proxy.hsts ? `HSTS ${proxy.hsts ? "enabled" : "disabled"}` : null,
|
||||
before.accessListId !== proxy.accessListId ? (proxy.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||
before.healthEnabled !== proxy.healthEnabled ? `health checks ${proxy.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
|
||||
]);
|
||||
res.json(publicProxy(proxy));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -2410,6 +2446,7 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
|
||||
try {
|
||||
const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." });
|
||||
const previousDomains = normalizeDomains(item.domain, item.domains);
|
||||
const before = { name: item.name, target: item.target, accessListId: item.accessListId, enabled: item.enabled, code: item.code, preservePath: item.preservePath, tls: item.tls, hsts: item.hsts };
|
||||
if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; }
|
||||
if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled);
|
||||
for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim();
|
||||
@@ -2418,7 +2455,19 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
|
||||
if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath);
|
||||
if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls;
|
||||
if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts);
|
||||
await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” updated.`); res.json(item);
|
||||
await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects();
|
||||
summarizeUpdate("Redirect Host", item.name, [
|
||||
before.name !== item.name ? `renamed from “${before.name}”` : null,
|
||||
describeDomainsChange(previousDomains, normalizeDomains(item.domain, item.domains)),
|
||||
before.target !== item.target ? `destination changed to ${item.target}` : null,
|
||||
before.code !== item.code ? `redirect code changed to ${item.code}` : null,
|
||||
before.preservePath !== item.preservePath ? `path preservation ${item.preservePath ? "enabled" : "disabled"}` : null,
|
||||
before.tls !== item.tls ? `TLS set to ${item.tls}` : null,
|
||||
before.hsts !== item.hsts ? `HSTS ${item.hsts ? "enabled" : "disabled"}` : null,
|
||||
before.accessListId !== item.accessListId ? (item.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||
before.enabled !== item.enabled ? (item.enabled ? "enabled" : "disabled") : null
|
||||
]);
|
||||
res.json(item);
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.delete("/api/redirects/:id", async (req, res, next) => {
|
||||
@@ -2450,6 +2499,7 @@ app.post("/api/streams", async (req, res, next) => {
|
||||
app.patch("/api/streams/:id", async (req, res, next) => {
|
||||
try {
|
||||
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
|
||||
const before = { name: stream.name, port: stream.port, target: stream.target, tcp: stream.tcp, udp: stream.udp, healthEnabled: stream.healthEnabled, enabled: stream.enabled };
|
||||
const next_ = { ...stream };
|
||||
if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; }
|
||||
if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; }
|
||||
@@ -2465,7 +2515,15 @@ app.patch("/api/streams/:id", async (req, res, next) => {
|
||||
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
|
||||
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
|
||||
await saveStreams();
|
||||
recordActivity(`Streaming host “${stream.name}” updated.`);
|
||||
const protocolLabel = (tcp, udp) => tcp && udp ? "TCP+UDP" : tcp ? "TCP" : "UDP";
|
||||
summarizeUpdate("Streaming host", stream.name, [
|
||||
before.name !== stream.name ? `renamed from “${before.name}”` : null,
|
||||
before.port !== stream.port ? `port changed from ${before.port} to ${stream.port}` : null,
|
||||
before.target !== stream.target ? `target changed to ${stream.target}` : null,
|
||||
(before.tcp !== stream.tcp || before.udp !== stream.udp) ? `protocol changed to ${protocolLabel(stream.tcp, stream.udp)}` : null,
|
||||
before.healthEnabled !== stream.healthEnabled ? `health checks ${stream.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||
before.enabled !== stream.enabled ? (stream.enabled ? "enabled" : "disabled") : null
|
||||
]);
|
||||
res.json(publicStream(stream));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user