|
|
|
@@ -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>`; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|