m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async export) into a new "Backup Mode" surface gated by adminGate. m's calls (all 4 material picks per design §2): - Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only) - Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved - paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally - Scheduler (Slice B): nightly 03:00 UTC, env-tunable Wiring: - mig 123 adds paliad.backups catalog table (kind/status/storage_uri/ size/row_counts/warnings/error/deleted_at + admin-only RLS). - ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for snapshot consistency (design §3.3). - writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx (org snapshot path) work. - BackupRunner orchestrates: catalog INSERT → audit INSERT (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch catalog + audit on success/failure. - ArtifactStore interface + LocalDiskStore impl (defense-in-depth key validation + URI-outside-dir guard). - Sentinel actor for scheduled runs: actor_email='system@paliad', actor_id=NULL — no phantom user in paliad.users. - Admin handlers POST /api/admin/backups/run + GET list/get/download behind adminGate(users, …); /admin/backups page + sidebar entry + bilingual i18n keys. - BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return 503 otherwise (same shape as requireDB). Tests: 8 pure-function tests cover registry shape (no dups, paliadin absent both as sheet name and SQL substring, ref__* sheets unscoped, every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key rejection, URI-traversal rejection, mkdir on construction). go build ./... + go test ./internal/... clean. bun run build clean. Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish) are separate follow-ups per head's instruction.
193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
import { initI18n, t } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
// Backup Mode admin client (t-paliad-246 / m/paliad#77 Slice A).
|
|
//
|
|
// Reads /api/admin/backups (chronological list) and wires the
|
|
// "Backup jetzt erstellen" button to POST /api/admin/backups/run.
|
|
// Synchronous: the server holds the connection for the duration of
|
|
// the backup (sub-second at firm-scale today), then returns the new
|
|
// catalog row inline. No polling needed at v1's data shape; if the
|
|
// run takes > 5 minutes the handler returns 500 and the UI surfaces
|
|
// the error.
|
|
|
|
interface BackupRow {
|
|
id: string;
|
|
kind: "scheduled" | "on_demand";
|
|
status: "running" | "done" | "failed";
|
|
requested_by?: string;
|
|
requested_by_email: string;
|
|
audit_id?: string;
|
|
storage_uri?: string;
|
|
size_bytes?: number;
|
|
row_counts?: unknown; // jsonb passes through as raw bytes; we don't read it
|
|
sheet_count?: number;
|
|
warnings?: unknown;
|
|
error?: string;
|
|
started_at: string;
|
|
finished_at?: string;
|
|
deleted_at?: string;
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
initI18n();
|
|
initSidebar();
|
|
|
|
await refreshList();
|
|
wireRunButton();
|
|
});
|
|
|
|
function wireRunButton(): void {
|
|
const btn = document.getElementById("admin-backups-run-btn") as HTMLButtonElement | null;
|
|
if (!btn) return;
|
|
btn.addEventListener("click", async () => {
|
|
btn.disabled = true;
|
|
const originalText = btn.textContent;
|
|
btn.textContent = t("admin.backups.running") || "Läuft …";
|
|
clearFeedback();
|
|
try {
|
|
const r = await fetch("/api/admin/backups/run", {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
});
|
|
if (!r.ok) {
|
|
const body = await r.json().catch(() => ({ error: "request failed" }));
|
|
showFeedback("error", body.error || `HTTP ${r.status}`);
|
|
return;
|
|
}
|
|
// The created row is in the response; refresh the list to land it.
|
|
await refreshList();
|
|
showFeedback("success", t("admin.backups.success") || "Backup erfolgreich erstellt.");
|
|
} catch (e) {
|
|
showFeedback("error", (e as Error).message || "network error");
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = originalText;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function refreshList(): Promise<void> {
|
|
const rows = await fetchJSON<BackupRow[]>("/api/admin/backups?limit=200");
|
|
const tbody = document.getElementById("admin-backups-tbody") as HTMLTableSectionElement | null;
|
|
const empty = document.getElementById("admin-backups-empty") as HTMLElement | null;
|
|
if (!tbody) return;
|
|
if (!rows || rows.length === 0) {
|
|
tbody.innerHTML = "";
|
|
if (empty) empty.style.display = "";
|
|
return;
|
|
}
|
|
if (empty) empty.style.display = "none";
|
|
tbody.innerHTML = rows.map(renderRow).join("");
|
|
}
|
|
|
|
function renderRow(b: BackupRow): string {
|
|
const started = formatTimestamp(b.started_at);
|
|
const kind =
|
|
b.kind === "scheduled"
|
|
? t("admin.backups.kind.scheduled") || "Geplant"
|
|
: t("admin.backups.kind.on_demand") || "Manuell";
|
|
const status = renderStatus(b);
|
|
const requestedBy =
|
|
b.kind === "scheduled" ? "—" : escapeHTML(b.requested_by_email);
|
|
const size = b.size_bytes != null ? formatBytes(b.size_bytes) : "—";
|
|
const rows = b.sheet_count != null ? String(b.sheet_count) : "—";
|
|
const action = renderAction(b);
|
|
return `<tr>
|
|
<td>${started}</td>
|
|
<td>${kind}</td>
|
|
<td>${status}</td>
|
|
<td>${requestedBy}</td>
|
|
<td>${size}</td>
|
|
<td>${rows}</td>
|
|
<td>${action}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function renderStatus(b: BackupRow): string {
|
|
switch (b.status) {
|
|
case "done":
|
|
return `<span class="status-done">${escapeHTML(t("admin.backups.status.done") || "✓ Fertig")}</span>`;
|
|
case "running":
|
|
return `<span class="status-running">${escapeHTML(t("admin.backups.status.running") || "Läuft …")}</span>`;
|
|
case "failed":
|
|
const label = t("admin.backups.status.failed") || "✗ Fehlgeschlagen";
|
|
const tip = b.error ? ` title="${escapeAttr(b.error)}"` : "";
|
|
return `<span class="status-failed"${tip}>${escapeHTML(label)}</span>`;
|
|
default:
|
|
return escapeHTML(b.status);
|
|
}
|
|
}
|
|
|
|
function renderAction(b: BackupRow): string {
|
|
if (b.status !== "done" || !b.storage_uri || b.deleted_at) {
|
|
return "—";
|
|
}
|
|
const label = t("admin.backups.download") || "Download";
|
|
return `<a class="btn-link" href="/api/admin/backups/${encodeURIComponent(b.id)}/file">${escapeHTML(label)}</a>`;
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
async function fetchJSON<T>(url: string): Promise<T | null> {
|
|
try {
|
|
const r = await fetch(url, { credentials: "same-origin" });
|
|
if (!r.ok) return null;
|
|
return (await r.json()) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function formatTimestamp(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return escapeHTML(iso);
|
|
const yyyy = d.getUTCFullYear();
|
|
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
const dd = String(d.getUTCDate()).padStart(2, "0");
|
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
const mi = String(d.getUTCMinutes()).padStart(2, "0");
|
|
return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
|
|
}
|
|
|
|
function formatBytes(n: number): string {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
|
|
function escapeHTML(s: string): string {
|
|
return s.replace(/[&<>"']/g, (c) => {
|
|
switch (c) {
|
|
case "&": return "&";
|
|
case "<": return "<";
|
|
case ">": return ">";
|
|
case '"': return """;
|
|
case "'": return "'";
|
|
default: return c;
|
|
}
|
|
});
|
|
}
|
|
|
|
function escapeAttr(s: string): string {
|
|
return escapeHTML(s);
|
|
}
|
|
|
|
function showFeedback(kind: "success" | "error", text: string): void {
|
|
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.classList.remove("form-msg-success", "form-msg-error");
|
|
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
|
|
el.style.display = "";
|
|
}
|
|
|
|
function clearFeedback(): void {
|
|
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
|
if (!el) return;
|
|
el.style.display = "none";
|
|
el.textContent = "";
|
|
el.classList.remove("form-msg-success", "form-msg-error");
|
|
}
|