Files
paliad/frontend/src/client/admin-backups.ts
mAi 99c9d89daa feat(backups): t-paliad-246 — Backup Mode Slice A (on-demand admin org export)
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.
2026-05-25 15:28:37 +02:00

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 "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
case "'": return "&#39;";
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");
}