Compare commits

...

2 Commits

5 changed files with 24 additions and 16 deletions
+4
View File
@@ -248,3 +248,7 @@ Roughly in priority order:
`v0.16.61` reverts v0.16.60's panel-centering approach and fixes the same empty-space issue correctly. The actual cause was the lede paragraph's own `max-width:850px`, set inside a `.docs-intro` panel that already spans the full reading-column width (matching the Contents sidebar + article layout beneath it) -- the paragraph simply stopped well short of the panel's real width. Removing that max-width lets the paragraph flow to fill the panel it already sits in, while the panel itself stays full-width and aligned with the rest of the manual, exactly as it was before v0.16.60.
`v0.16.62` fixes two Dashboard issues found while reviewing a fresh screenshot for the marketing site. First, the Certificates tile's big number and its "X healthy · Y not detected" line disagreed with each other (e.g. showing "11" above "12 healthy") because they came from two different counts: the big number used `tlsDomains`, a route-count over enabled Hosted Sites and Proxy Hosts with TLS on, while the detail line used the certificate inventory's domain-level summary -- which also includes Redirect Hosts and expands any route with alias domains into one entry per domain. The big number now reads from that same certificate summary the detail line already used, so a route with an alias domain (the actual cause of the 11-vs-12 mismatch) is counted consistently in both places; the underlying route-count (`tlsDomains`) is untouched where it's used to gate the HTTPS/443 health probe, since that is a genuinely different question ("is TLS configured at all") from "how many certificates exist." Second, the Certificates tile's icon was a plain unicode square (`▣`) left over from before the tile had a real icon design -- it's now a small key glyph drawn as an inline SVG in the app's existing icon style (stroke-based, `currentColor`), matching the key icon used for Certificates on the marketing website and requested directly against a reference image.
`v0.16.63` converts the Backup history panel (Administration -> Backup & Restore) from a stacked-card timeline to the same pinned-header table used everywhere else records are logged -- Access Logs and Gateway Events. It was the one remaining place in the app presenting a log-like record as a list of `.activity-tile` cards (the same component the Dashboard's Recent Activity feed uses) instead of a table, and looked inconsistent sitting next to those two. The table has four columns -- Time, Action (Backup/Restored/Deleted/Imported), Type (Complete/Configuration/Safety), and Detail (size, a note that a safety backup was taken first, or the failure reason) -- reusing the exact `performance-table`/`event-table` CSS and column widths Gateway Events already established, plus the same green/red `status-dot` convention for success vs. failure. No backend or data changes; `/api/backups/history` already returned everything the new columns needed.
`v0.16.64` fixes two issues reported right after v0.16.63 shipped the new Backup history table. First, creating, deleting, or importing a backup updated the "Stored backups" list immediately but left Backup history showing stale data until something else triggered a full page refresh -- each of those three handlers called `renderBackups()` afterward but never `renderBackupHistory()`; only Restore happened to update it, incidentally, because it runs the app's full `refresh()` cycle afterward for unrelated reasons. All three now also call `renderBackupHistory()` directly, so every action that writes a history entry reflects it immediately, matching what Restore already did by accident. Second, the table looked lopsided -- all its content bunched on the left with a large empty gap on the right -- because it reused Gateway Events' column widths (`190px/110px/140px/auto`), sized for a Message column that's usually full of long text; Backup history's last column is normally a short phrase like "71 MB", so the table stretched to the full panel width with nothing to fill it. The table now has its own column widths (26%/18%/18%/auto) inside a capped max-width (880px) instead of stretching edge-to-edge, so it reads as one balanced block rather than left-aligned content trailing into empty space.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.16.62",
"version": "0.16.64",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+13 -7
View File
@@ -237,9 +237,9 @@ const factoryResetForm = document.querySelector("#factory-reset-form"), factoryU
document.querySelector("#import-backup").addEventListener("click", () => document.querySelector("#backup-upload").click());
async function themedConfirm(title, message, actionLabel = "Continue") { let dialog = document.querySelector("#backup-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "backup-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>${title}</h2></div></div><p class="muted">${message}</p><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">${actionLabel}</button></div></form>`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); }
document.querySelector("#backup-upload").addEventListener("change", async event => { const file = event.target.files[0]; if (!file) return; const data = new FormData(), password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; data.append("backup", file); data.append("password", password); try { await api("/api/backups/import", { method:"POST", body:data }); state.backups = await api("/api/backups"); renderBackups(); toast("Backup imported. Review it before restoring."); } catch (error) { toast(error.message); } finally { event.target.value = ""; } });
document.querySelector("#backup-upload").addEventListener("change", async event => { const file = event.target.files[0]; if (!file) return; const data = new FormData(), password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; data.append("backup", file); data.append("password", password); try { await api("/api/backups/import", { method:"POST", body:data }); state.backups = await api("/api/backups"); renderBackups(); renderBackupHistory(); toast("Backup imported. Review it before restoring."); } catch (error) { toast(error.message); } finally { event.target.value = ""; } });
document.querySelector("#backup-list").addEventListener("click", async event => { const button = event.target.closest("[data-backup-action]"), row = button?.closest("[data-backup]"); if (!button || !row) return; const filename = row.dataset.backup; try { if (button.dataset.backupAction === "delete") { if (!await themedConfirm("Delete backup?", `This permanently removes ${filename}. It cannot be restored unless you have another copy.`, "Delete backup")) return; await api(`/api/backups/${encodeURIComponent(filename)}`, {method:"DELETE"}); } else { if (!await themedConfirm("Restore this backup?", "Current data will be replaced after a safety backup is created. Site Gateway validates the archive and can roll back if restoration fails.", "Restore backup")) return; const password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; await api(`/api/backups/${encodeURIComponent(filename)}/restore`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password})}); await refresh(); } state.backups = await api("/api/backups"); renderBackups(); toast(button.dataset.backupAction === "delete" ? "Backup deleted." : "Backup restored."); } catch (error) { toast(error.message); } });
document.querySelector("#backup-list").addEventListener("click", async event => { const button = event.target.closest("[data-backup-action]"), row = button?.closest("[data-backup]"); if (!button || !row) return; const filename = row.dataset.backup; try { if (button.dataset.backupAction === "delete") { if (!await themedConfirm("Delete backup?", `This permanently removes ${filename}. It cannot be restored unless you have another copy.`, "Delete backup")) return; await api(`/api/backups/${encodeURIComponent(filename)}`, {method:"DELETE"}); } else { if (!await themedConfirm("Restore this backup?", "Current data will be replaced after a safety backup is created. Site Gateway validates the archive and can roll back if restoration fails.", "Restore backup")) return; const password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; await api(`/api/backups/${encodeURIComponent(filename)}/restore`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password})}); await refresh(); } state.backups = await api("/api/backups"); renderBackups(); renderBackupHistory(); toast(button.dataset.backupAction === "delete" ? "Backup deleted." : "Backup restored."); } catch (error) { toast(error.message); } });
const restoreButton = document.querySelector("#restore-defaults"), restoreCredentialsBlock = document.querySelector(".danger-credentials"); if (restoreButton && restoreCredentialsBlock && !restoreButton.closest(".restore-form")) { const restoreForm = document.createElement("form"); restoreForm.className = "danger-form restore-form"; restoreCredentialsBlock.replaceWith(restoreForm); restoreForm.append(restoreCredentialsBlock, restoreButton); } const restoreCancel = document.createElement("button"); restoreCancel.type = "button"; restoreCancel.className = "button secondary"; restoreCancel.textContent = "Cancel"; restoreCancel.id = "restore-defaults-cancel"; const restoreActions = document.createElement("div"); restoreActions.className = "danger-actions"; restoreButton.parentNode.insertBefore(restoreActions, restoreButton); restoreActions.append(restoreCancel, restoreButton); restoreCancel.addEventListener("click", () => { document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; }); document.querySelector("#factory-reset-cancel")?.addEventListener("click", () => { document.querySelector("#factory-reset-form").reset(); document.querySelector("#factory-reset-error").textContent = ""; });
@@ -310,7 +310,7 @@ if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) {
}
// Manual "Create backup" button.
document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form method="dialog" class="dialog-card backup-create-dialog"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>Create a backup</h2></div></div><p class="muted">Choose what to include. Site Gateway saves a copy in <code>/data/backups</code> and downloads a copy to your computer.</p><label>Backup type<select name="type"><option value="complete">Complete (Recommended)</option><option value="configuration">Configuration only</option></select></label><small class="backup-dialog-help">Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups include settings and metadata, but not uploaded Hosted Site files.</small><label>Encryption password <span class="optional">Optional</span><input name="password" type="password" placeholder="Optional — enter a password" autocomplete="new-password"></label><small class="backup-dialog-help">If provided, this password is required to restore the downloaded archive.</small><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create backup</button></div></form>'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true);
document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form method="dialog" class="dialog-card backup-create-dialog"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>Create a backup</h2></div></div><p class="muted">Choose what to include. Site Gateway saves a copy in <code>/data/backups</code> and downloads a copy to your computer.</p><label>Backup type<select name="type"><option value="complete">Complete (Recommended)</option><option value="configuration">Configuration only</option></select></label><small class="backup-dialog-help">Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups include settings and metadata, but not uploaded Hosted Site files.</small><label>Encryption password <span class="optional">Optional</span><input name="password" type="password" placeholder="Optional — enter a password" autocomplete="new-password"></label><small class="backup-dialog-help">If provided, this password is required to restore the downloaded archive.</small><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create backup</button></div></form>'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); renderBackupHistory(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true);
// --- Logs & Retention admin panel (Administration > Logs & Retention) ------------
@@ -470,13 +470,19 @@ function backupHistoryLabel(event) {
if (event.type === "imported") return `Imported ${lower}${when}`;
return `${kind}${when}`;
}
function backupHistoryAction(event) {
return event.type === "restored" ? "Restored" : event.type === "deleted" ? "Deleted" : event.type === "imported" ? "Imported" : "Backup";
}
function backupHistoryType(event) {
return event.backupType === "complete" ? "Complete" : event.backupType === "configuration" ? "Configuration" : event.backupType === "safety" ? "Safety (pre-restore)" : "—";
}
async function renderBackupHistory() {
const panel = document.querySelector('[data-admin-panel="backups"]'); if (!panel || state.user?.role !== "administrator") return;
let section = panel.querySelector(".backup-history-section");
if (!section) {
section = document.createElement("div");
section.className = "dashboard-panel backup-history-section";
section.innerHTML = '<div class="panel-heading"><div><p class="eyebrow">History</p><h2>Backup history</h2><p class="muted">Every backup, restore, import, and deletion — including failed attempts — recorded independently of what is currently stored on disk.</p></div></div><div id="backup-history-list" class="backup-history-list"><p class="quiet-state">Loading backup history…</p></div>';
section.innerHTML = '<div class="panel-heading"><div><p class="eyebrow">History</p><h2>Backup history</h2><p class="muted">Every backup, restore, import, and deletion — including failed attempts — recorded independently of what is currently stored on disk.</p></div></div><div class="table-wrap performance-table-wrap event-table-wrap backup-history-wrap"><table class="performance-table event-table backup-history-table"><thead><tr><th>Time</th><th>Action</th><th>Type</th><th>Detail</th></tr></thead><tbody id="backup-history-list"><tr><td colspan="4" class="quiet-state">Loading backup history…</td></tr></tbody></table></div>';
panel.append(section);
}
const list = section.querySelector("#backup-history-list");
@@ -485,9 +491,9 @@ async function renderBackupHistory() {
list.innerHTML = events.length ? events.map(item => {
const failed = item.status === "failed";
const detail = failed ? `Failed — ${item.errorMessage || "no further detail recorded"}` : [item.sizeBytes ? formatBytes(item.sizeBytes) : "", item.safetyBackupFilename ? "A safety backup was taken first" : ""].filter(Boolean).join(" · ") || "Completed";
return `<div class="activity-tile"><span class="activity-mark ${failed ? "bad" : ""}">${failed ? "!" : "✓"}</span><span class="backup-history-copy"><strong>${extendedEscape(backupHistoryLabel(item))}</strong><small class="${failed ? "failed" : ""}" title="${extendedEscape(formatTime(item.createdAt))}">${extendedEscape(detail)}</small></span></div>`;
}).join("") : '<p class="quiet-state">No backup activity recorded yet.</p>';
} catch (error) { list.innerHTML = `<p class="quiet-state">${extendedEscape(error.message)}</p>`; }
return `<tr><td>${extendedEscape(formatTime(item.createdAt))}</td><td>${extendedEscape(backupHistoryAction(item))}</td><td>${extendedEscape(backupHistoryType(item))}</td><td><span class="status-dot ${failed ? "error" : "running"}"></span>${extendedEscape(detail)}</td></tr>`;
}).join("") : '<tr><td colspan="4" class="quiet-state">No backup activity recorded yet.</td></tr>';
} catch (error) { list.innerHTML = `<tr><td colspan="4" class="quiet-state">${extendedEscape(error.message)}</td></tr>`; }
}
+2 -2
View File
@@ -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.62">
<link rel="stylesheet" href="/styles.css?v=0.16.64">
</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.62" defer></script><script src="/features.js?v=0.16.62" defer></script><script src="/select-enhance.js?v=0.16.62" defer></script>
<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>
</body>
</html>
+4 -6
View File
@@ -262,6 +262,10 @@ 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}
.activity-mark.bad{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
.feature-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:18px}
.feature-summary>div{padding:18px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
@@ -994,12 +998,6 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.api-token-hide-revoked span{line-height:normal}
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
/* Backup history timeline */
.backup-history-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}
.backup-history-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
.backup-history-copy strong{font-size:var(--font-size-md)}
.backup-history-copy small{color:var(--muted);font-size:var(--font-size-xs)}
.backup-history-copy small.failed{color:var(--danger)}
.check-control.is-disabled{opacity:.55}
.check-control.is-disabled span{color:var(--muted)}
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}