"use client"; /** * `` — admin view of the weekly `DataBackup` snapshots. * * Lists every snapshot row (one per user × backup-type) with size and age, * plus a "Run backup now" CTA that enqueues the pg-boss `data-backup` job. * The encrypted payload itself is never shipped to the browser; only the * size in bytes is surfaced. * * Folds in v1.4.6 deferred T2.6. */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, BookOpen, Database, Download, History, Loader2, PlayCircle, Upload, } from "lucide-react"; import { useRef, useState, type ChangeEvent } from "react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ListRow } from "@/components/ui/list-row"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { formatBytes } from "@/lib/format/bytes"; import { queryKeys } from "@/lib/query-keys"; import { randomId } from "@/lib/random-id"; import type { BackupRow, BackupsList } from "@/types/backups"; import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status"; import type { MissingBackupSection, RestoreSkipSummary, SkippedCatalogue, } from "@/lib/export/restore-skips"; import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore"; import { getApiErrorMessage } from "./_shared"; import { isActiveRestore, RestoreJobStatus, visibleRestoreJobs, } from "./backup-restore-status"; import { ApiError, apiFetch, apiFetchRaw, apiGet, apiPost, } from "@/lib/api/api-fetch"; /** * Typed-confirmation dialog for restore. The destructive Restore button * is gated behind: * 1. Opening the dialog (one click). * 2. Reading the warning copy (Title + Description spell out exactly * what's about to happen + which user it affects). * 3. Typing the literal string `RESTORE` into the prompt input — * anything else keeps the confirm button disabled. * * Three independent steps before the request fires == "triple confirm". * Mirrors the wipe dialog's pattern but adds the typed gate because the * blast radius is bigger (re-creates rows, not just deletes). */ /** * v1.37.20 — the sections a restore preview names, in render order. Each * entry sums one or more `BackupSummary` counters under one human label; * every counter NOT named here still reaches the operator through the * trailing "other records" line, so a future summary key can be forgotten * here without silently vanishing from the preview. */ const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{ labelKey: string; keys: readonly string[]; }> = [ { labelKey: "previewMeasurements", keys: ["measurements"] }, { labelKey: "previewMedications", keys: ["medications", "intakeEvents", "medicationSideEffects"], }, { labelKey: "previewMood", keys: ["moodEntries"] }, { labelKey: "previewLabs", keys: ["labResults", "biomarkers"] }, { labelKey: "previewDocuments", keys: ["documents"] }, { labelKey: "previewWorkouts", keys: ["workouts"] }, { labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] }, { labelKey: "previewVisits", keys: ["encounters", "practitioners", "encounterLinks"], }, { labelKey: "previewVaccinations", keys: ["vaccinations", "vaccinationLinks"], }, { labelKey: "previewReminders", keys: ["measurementReminders", "measurementReminderEvents"], }, { labelKey: "previewCustomMetrics", keys: ["customMetrics", "customMetricEntries"], }, { labelKey: "previewHealthScores", keys: ["healthScoreRecords"] }, ]; function RestoreRowDialog({ row, pending, onConfirm, }: { row: BackupRow; pending: boolean; onConfirm: (options: { restoreInstanceSettings: boolean }) => void; }) { const { t } = useTranslations(); const fmt = useFormatters(); const [open, setOpen] = useState(false); const [typed, setTyped] = useState(""); const matched = typed.trim() === "RESTORE"; // Off unless the operator says otherwise. A disaster-recovery snapshot // carries the host's own settings alongside the account's data, and putting // one account back is not a reason to reconfigure the installation for // everybody on it — so the second effect is a second decision. const [withInstanceSettings, setWithInstanceSettings] = useState(false); // v1.37.20 — restore preview: fetch what the file contains the moment the // dialog opens, so the typed confirmation is an informed one. Derived from // the same decrypt + schema path the restore itself runs. A failed preview // never blocks the restore — it states its own absence instead. const preview = useQuery({ queryKey: queryKeys.adminBackupSummary(row.id), queryFn: () => apiGet<{ summary: Record }>( `/api/admin/backups/${row.id}/summary`, ), enabled: open, staleTime: 60_000, }); const numericSummary: Record = {}; if (preview.data?.summary) { for (const [key, value] of Object.entries(preview.data.summary)) { if (typeof value === "number") numericSummary[key] = value; } } const namedKeys = new Set( RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys), ); const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({ labelKey: section.labelKey, count: section.keys.reduce( (sum, key) => sum + (numericSummary[key] ?? 0), 0, ), })).filter((entry) => entry.count > 0); const otherCount = Object.entries(numericSummary) .filter(([key]) => !namedKeys.has(key)) .reduce((sum, [, value]) => sum + value, 0); return ( { setOpen(next); if (!next) { setTyped(""); setWithInstanceSettings(false); } }} > {t("admin.section.backups.restoreTitle")} {t("admin.section.backups.restoreDescription", { username: row.username, when: fmt.dateTime(row.createdAt), })} {/* v1.37.20 — what the file contains, before the typed gate. */}

{t("admin.section.backups.previewTitle")}

{preview.isLoading && (

{t("admin.section.backups.previewLoading")}

)} {preview.isError && (

{t("admin.section.backups.previewUnavailable")}

)} {preview.isSuccess && (
    {previewRows.map((entry) => (
  • {t(`admin.section.backups.${entry.labelKey}`)} {fmt.integer(entry.count)}
  • ))} {otherCount > 0 && (
  • {t("admin.section.backups.previewOther")} {fmt.integer(otherCount)}
  • )} {previewRows.length === 0 && otherCount === 0 && (
  • {t("admin.section.backups.previewEmpty")}
  • )}
)}
{/* The second decision, before the typed gate rather than after it: the account's data comes back either way, the host's settings only if this is ticked. */}
setTyped(e.target.value)} autoComplete="off" spellCheck={false} placeholder="RESTORE" />
{t("common.cancel")} { if (!matched) return; setOpen(false); setTyped(""); onConfirm({ restoreInstanceSettings: withInstanceSettings }); setWithInstanceSettings(false); }} > {pending ? t("admin.section.backups.restoreInProgress") : t("admin.section.backups.restoreConfirm")}
); } /** * Render the `DataBackup.type` enum (`WEEKLY_AUTO` / `MANUAL`) as a * human label. Unknown values fall through to the raw enum so a future * type added in the schema is still legible at a glance. */ function formatBackupType( type: string, t: ReturnType["t"], ) { if (type === "WEEKLY_AUTO") return t("admin.section.backups.typeWeeklyAuto"); if (type === "MANUAL") return t("admin.section.backups.typeManual"); return type; } /** * Which catalogue a skipped key belonged to, as a label. * * Spelled out with literal `t()` calls rather than an interpolated key, so * `i18n-call-site-coverage.test.ts` can see each one and prove the bundle has * it. An interpolated key is invisible to that guard and shows up as raw dot * notation in production the day someone renames a catalogue. */ function catalogueLabel( catalogue: SkippedCatalogue, t: ReturnType["t"], ) { if (catalogue === "cycleSymptom") { return t("admin.section.backups.restoreSkippedCycleSymptom"); } if (catalogue === "illnessSymptom") { return t("admin.section.backups.restoreSkippedIllnessSymptom"); } if (catalogue === "moodTag") { return t("admin.section.backups.restoreSkippedMoodTag"); } if (catalogue === "visitReference") { return t("admin.section.backups.restoreSkippedVisitReference"); } if (catalogue === "vaccinationReference") { return t("admin.section.backups.restoreSkippedVaccinationReference"); } if (catalogue === "checkupClosure") { return t("admin.section.backups.restoreSkippedCheckupClosure"); } if (catalogue === "reminderReference") { return t("admin.section.backups.restoreSkippedReminderReference"); } if (catalogue === "coachAttachment") { return t("admin.section.backups.restoreSkippedCoachAttachment"); } if (catalogue === "coachReference") { return t("admin.section.backups.restoreSkippedCoachReference"); } if (catalogue === "documentConditionLink") { return t("admin.section.backups.restoreSkippedDocumentConditionLink"); } if (catalogue === "extractedFact") { return t("admin.section.backups.restoreSkippedExtractedFact"); } if (catalogue === "factCommitment") { return t("admin.section.backups.restoreSkippedFactCommitment"); } if (catalogue === "moodFactor") { return t("admin.section.backups.restoreSkippedMoodFactor"); } if (catalogue === "personalRecordReference") { return t("admin.section.backups.restoreSkippedPersonalRecordReference"); } if (catalogue === "ecgReference") { return t("admin.section.backups.restoreSkippedEcgReference"); } if (catalogue === "medicationTarget") { return t("admin.section.backups.restoreSkippedMedicationTarget"); } if (catalogue === "scheduleRevisionLink") { return t("admin.section.backups.restoreSkippedScheduleRevisionLink"); } // Not a fallback: the chain above is exhaustive and this line is what makes // the compiler say so. A catalogue added without a label here now stops the // build instead of shipping under a label that belongs to something else, // which is what three of them were already doing — a dropped Coach // attachment reached the operator's screen labelled "mood factor". const unlabelled: never = catalogue; return unlabelled; } /** * Which section a refused file claimed and did not carry, as a label. * * Spelled out with literal `t()` calls for the same reason as * `catalogueLabel` above — an interpolated key is invisible to * `i18n-call-site-coverage.test.ts`, and a renamed section would ship as raw * dot notation. The `never` at the foot is what makes the compiler say the * chain is exhaustive. */ function missingSectionLabel( section: MissingBackupSection, t: ReturnType["t"], ) { if (section === "documents") { return t("admin.section.backups.restoreMissingDocuments"); } if (section === "workouts") { return t("admin.section.backups.restoreMissingWorkouts"); } if (section === "mentalHealth") { return t("admin.section.backups.restoreMissingMentalHealth"); } if (section === "consent") { return t("admin.section.backups.restoreMissingConsent"); } const unlabelled: never = section; return unlabelled; } /** * Why a file was refused, and what to do about it. * * Held on screen rather than left to the toast, exactly like the skip report * below it. The operator refused here is often holding the only copy of an * account; "restore failed" for four seconds is not enough to act on, and the * two facts that let them act — which section the file claimed and did not * carry, and that nothing was changed — have to survive long enough to be read. */ /** * The refused sections a failed restore job names, or none. * * Keyed on the failure's `code` and not on its message: the message is prose * the server may reword, and matching on it would turn a copy edit into a * silent loss of the panel. */ const DISMISSED_RESTORES_KEY = "healthlog.admin.backups.dismissedRestores"; /** Dismissed restore panels, from this browser. Empty when storage is off. */ function readDismissedRestores(): string[] { try { if (typeof window === "undefined") return []; const raw = window.localStorage.getItem(DISMISSED_RESTORES_KEY); const parsed: unknown = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : []; } catch { return []; } } function writeDismissedRestores(ids: string[]): void { try { // The list only ever shows a day of jobs; fifty ids is weeks of them. window.localStorage.setItem( DISMISSED_RESTORES_KEY, JSON.stringify(ids.slice(-50)), ); } catch { // A private window: the panel comes back on reload, nothing else breaks. } } /** * The upload body for a backup file: the file itself when it is already * gzip, otherwise the file compressed in the browser. The server accepts * both; compressing keeps a large export well inside the request size limit. */ async function uploadBody(file: File): Promise { const head = new Uint8Array(await file.slice(0, 2).arrayBuffer()); if (head[0] === 0x1f && head[1] === 0x8b) return file; return new Response( file.stream().pipeThrough(new CompressionStream("gzip")), ).blob(); } export function missingSectionsOf( failure: { code: string; sections?: readonly unknown[] } | null | undefined, ): MissingBackupSection[] { if (failure?.code !== "backup.section.missing") return []; const sections = failure.sections; if (!Array.isArray(sections)) return []; return sections.filter( (section): section is MissingBackupSection => typeof section === "string", ); } export function RestoreRefusalNotice({ sections, onDismiss, }: { sections: MissingBackupSection[]; onDismiss: () => void; }) { const { t } = useTranslations(); if (sections.length === 0) return null; return (
); } /** * What the last restore could not put back. * * Its own component, and exported, so it can be rendered against a report in a * test instead of only through a state transition the SSR smoke tests here * cannot drive. A report that reaches the response and never reaches the screen * is the same silence the reporting exists to end, so the render is worth * proving on its own. * * It names every key rather than totalling them away: an operator who cannot * see WHICH symptom went missing cannot decide whether it mattered. */ export function RestoreSkipReport({ report, onDismiss, }: { report: RestoreSkipSummary; onDismiss: () => void; }) { const { t } = useTranslations(); if (report.links === 0) return null; return (
); } /** * Says out loud whether the SCHEDULE is still alive. * * The weekly pass on the maintainer's own instance stopped producing copies * and nothing surfaced it for a month and a half: the table listed the rows it * had, each with an ordinary timestamp, and a copy from six weeks ago reads * exactly like one made on Sunday until somebody does the subtraction. Two * independent facts are shown, because they fail at different times — the age * of the newest scheduled copy never ages out, while the run's own outcome * carries the reason ("job timed out") and survives only as long as pg-boss * keeps the row. * * Not dismissible: it reflects live state, so the only way to clear it is to * make a backup succeed. */ function ScheduleHealthNotice({ schedule, }: { schedule: BackupScheduleStatus; }) { const { t } = useTranslations(); const fmt = useFormatters(); if (!schedule.stale && !schedule.lastRunFailed) return null; return (
); } export function BackupsSection() { const { t } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), queryFn: async () => { return apiGet("/api/admin/backups"); }, }); const runBackup = useMutation({ mutationFn: async () => { return apiPost<{ jobId: string | null }>("/api/admin/backups/run"); }, onSuccess: () => { toast.success(t("admin.section.backups.runEnqueued")); // The job runs async; refetch after a short delay so the new row // shows up without leaving the user wondering whether the click // did anything. setTimeout(() => { queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); }, 2000); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.runFailed"), ); }, }); const rows: BackupRow[] = data?.rows ?? []; // Per-row download in-flight state — keyed by backup id so two // parallel clicks on different rows don't share a single spinner. const [downloadingId, setDownloadingId] = useState(null); // Upload state — single-file flow, drives the file input + button. const fileInputRef = useRef(null); const upload = useMutation({ mutationFn: async (file: File) => { // Sent as the raw body, compressed on the way unless it already is: the // server reads it as a stream, and a large export (hundreds of MB of // JSON) shrinks about tenfold. No request timeout, because a file of // that size takes longer to check and store than the default allows. const body = await uploadBody(file); return apiFetch<{ id: string; valid: true; summary: { measurements: number; medications: number; intakeEvents: number; moodEntries: number; // Optional for the same reason as the keys below: a file written // before side effects rode the wire carries no key at all. medicationSideEffects?: number; cycles?: number; cycleDayLogs?: number; // v1.28 backup-completeness — optional so an older-schema upload // (pre-v1.28) still totals correctly without these keys. labResults?: number; biomarkers?: number; illnessEpisodes?: number; illnessDayLogs?: number; allergies?: number; familyHistory?: number; workouts?: number; documents?: number; // Optional for the same reason: a file written before each of these // rode the wire carries no key, and an absent key must total as // nothing rather than break the count. nutrientDays?: number; healthProfile?: number; healthProfileFactRevisions?: number; customMetrics?: number; customMetricEntries?: number; intradayProfiles?: number; }; }>("/api/admin/backups/upload", { method: "POST", body, headers: { "Content-Type": "application/gzip" }, signal: null, }); }, onSuccess: (data) => { const total = data.summary.measurements + data.summary.medications + data.summary.intakeEvents + data.summary.moodEntries + (data.summary.medicationSideEffects ?? 0) + (data.summary.cycles ?? 0) + (data.summary.cycleDayLogs ?? 0) + (data.summary.labResults ?? 0) + (data.summary.biomarkers ?? 0) + (data.summary.illnessEpisodes ?? 0) + (data.summary.illnessDayLogs ?? 0) + (data.summary.allergies ?? 0) + (data.summary.familyHistory ?? 0) + (data.summary.workouts ?? 0) + (data.summary.documents ?? 0) + // The count the admin is shown has to be the count the file carries. // The nutrient day totals were added to the payload and never to this // sum, so an upload that restored them reported fewer records than it // held — a smaller version of the same defect, on the reporting side. (data.summary.nutrientDays ?? 0) + (data.summary.healthProfile ?? 0) + (data.summary.healthProfileFactRevisions ?? 0) + (data.summary.customMetrics ?? 0) + (data.summary.customMetricEntries ?? 0) + (data.summary.intradayProfiles ?? 0); toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.uploadFailed"), ); }, onSettled: () => { // Reset the file input so the same file can be re-selected after a // rejected upload (browsers keep the previous selection otherwise). if (fileInputRef.current) fileInputRef.current.value = ""; }, }); function handleFileChange(e: ChangeEvent) { const file = e.target.files?.[0]; if (file) upload.mutate(file); } // What the last restore could not put back. Held in state rather than left // to the toast: a toast for "twelve symptom links are gone" disappears in // four seconds and the operator has no way to get it back. This panel stays // until they dismiss it. const [skipped, setSkipped] = useState(null); // Why the last restore was refused before it touched anything. Same reason // the skip report is held rather than toasted: the operator has to be able to // read it after the four seconds are up. const [refusedSections, setRefusedSections] = useState< MissingBackupSection[] >([]); // A job this page watched while it ran, and has now seen finish. Only those // raise a toast and refresh the page's data: a job that had already finished // when the page opened is shown in its panel and nothing more. Read in the // poll's own callback, where each answer arrives, rather than in an effect. const watched = useRef>(new Map()); function noticeFinishedRestores(jobs: readonly BackupRestoreJobView[]) { for (const job of jobs) { const before = watched.current.get(job.id); watched.current.set(job.id, job.status); if (before !== "queued" && before !== "running") continue; if (job.status === "succeeded") { setRefusedSections([]); const report = job.result?.skipped; if (report && report.links > 0) { setSkipped(report as RestoreSkipSummary); toast.warning( t("admin.section.backups.restoreSkippedToast", { links: String(report.links), }), ); } else { setSkipped(null); toast.success(t("admin.section.backups.restoreSuccess")); } // Restore touches every personal-data table; nuke the broader cache // so dashboards / lists rebuild against the new state. void queryClient.invalidateQueries(); } else if (job.status === "failed") { setRefusedSections(missingSectionsOf(job.failure)); toast.error(t("admin.section.backups.restoreFailed")); } } } // The restore jobs, read from the server so a reload during a restore finds // it again. Polled every two seconds while any is queued or running, and // not at all otherwise. A poll that fails keeps the last answer on screen: // a network blip is not a failed restore. const restores = useQuery({ queryKey: queryKeys.adminBackupRestores(), queryFn: async () => { const data = await apiGet<{ jobs: BackupRestoreJobView[] }>( "/api/admin/backups/restores", ); noticeFinishedRestores(data.jobs); return data; }, refetchInterval: (query) => query.state.data?.jobs.some(isActiveRestore) ? 2000 : false, }); const restoreJobs = restores.data?.jobs ?? []; const activeOwners = new Set( restoreJobs.filter(isActiveRestore).map((job) => job.userId), ); // Finished restores the operator dismissed. Remembered in this browser only, // for convenience; the list itself forgets a job after a day. const [dismissedRestores, setDismissedRestores] = useState>( () => new Set(readDismissedRestores()), ); function dismissRestore(id: string) { setDismissedRestores((previous) => { const next = new Set(previous); next.add(id); writeDismissedRestores([...next]); return next; }); } // Restore: typed-confirmation dialog. The request only queues the restore; // the panel above the list follows it from there. const restore = useMutation({ mutationFn: async ({ row, restoreInstanceSettings, }: { row: BackupRow; restoreInstanceSettings: boolean; }) => { // Idempotency-Key prevents a double-click from queuing a second // restore. Include the row id so two different backups can both be // restored independently in the same minute. const idempotencyKey = `restore-${row.id}-${randomId()}`; return apiPost<{ jobId: string; status: "queued" }>( `/api/admin/backups/${row.id}/restore`, { confirm: "RESTORE", restoreInstanceSettings }, { headers: { "Idempotency-Key": idempotencyKey } }, ); }, onSuccess: (data) => { setRefusedSections([]); setSkipped(null); watched.current.set(data.jobId, "queued"); // Neutral, not a success: nothing has been restored yet. toast.info(t("admin.section.backups.restoreStarted")); void queryClient.invalidateQueries({ queryKey: queryKeys.adminBackupRestores(), }); }, onError: (err) => { if ( err instanceof ApiError && err.meta?.errorCode === "backup.restore.active" ) { toast.error(t("admin.section.backups.restoreActive")); void queryClient.invalidateQueries({ queryKey: queryKeys.adminBackupRestores(), }); return; } setRefusedSections([]); toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.restoreFailed"), ); }, }); async function handleDownload(row: BackupRow) { setDownloadingId(row.id); try { // apiFetchRaw: the download needs the raw Response for the blob + // the Content-Disposition filename header. const res = await apiFetchRaw(`/api/admin/backups/${row.id}/download`); if (!res.ok) { throw new Error(await getApiErrorMessage(res)); } // Use the server-provided Content-Disposition filename if present, // otherwise fall back to a deterministic client-side name. The // server filename is more accurate (uses the actual createdAt). const cd = res.headers.get("content-disposition") ?? ""; const match = cd.match(/filename="?([^";]+)"?/i); const fallback = `healthlog-backup-${row.userId}-${row.createdAt.slice(0, 10)}.json`; const filename = match?.[1] ?? fallback; const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success(t("admin.section.backups.downloadStarted")); } catch (err) { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.downloadFailed"), ); } finally { setDownloadingId(null); } } return ( {/* v1.18.1 E3 — the snapshot count leads (numbers first) and the "Run now" button follows. */} {rows.length} ) : null } description={

{t("admin.section.backups.description")}{" "} {/* External docs link — `noopener noreferrer` because this leaves the admin shell. */}

} /> {data?.schedule ? ( ) : null} {/* Upload card — separate from the table so admins can ingest a backup file independently of any existing rows. The visible button proxies a hidden file input so the layout stays clean while keyboard / screen-reader users keep a labelled control. */}
{t("admin.section.backups.uploadTitle")}

{t("admin.section.backups.uploadDescription")}{" "} {t("admin.section.backups.uploadHelp")}

{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => ( dismissRestore(job.id)} /> ))} setRefusedSections([])} /> {skipped ? ( setSkipped(null)} /> ) : null} {isLoading ? (
{t("admin.section.backups.loading")}
) : isError ? ( void refetch()} /> ) : rows.length === 0 ? ( // v1.4.15 phase-C5: replace bare text with the EmptyState // primitive. The header already exposes "Backup now" but a // brand-new admin lands inside the card and benefits from a // duplicate CTA right next to the explanation. // // Its own test id, because both buttons are in the tree while the list // is empty and they do the same thing: one id on two elements forces // every caller to pick one blind, and a third button would join the // pick without anything failing.
} title={t("admin.section.backups.emptyTitle")} description={t("admin.section.backups.emptyDescription")} action={ } />
) : (
{/* Wide layout: the canonical table. Hidden below `md`, where the five columns push Download / Restore off-screen behind a horizontal scroll; the card list below takes over there. */}
{/* `backup-rows` names the read rows. The three branches above are a spinner, a load error and an empty state, so the marker cannot stand for a page that has not answered yet. The narrow-layout list below carries it too, so a wait on it holds at any viewport. */} {rows.map((row, i) => ( ))}
{t("admin.section.backups.colUser")} {t("admin.section.backups.colType")} {t("admin.section.backups.colSize")} {t("admin.section.backups.colCreatedAt")} {t("admin.section.backups.colActions")}
{row.username} {formatBackupType(row.type, t)} {formatBytes(row.sizeBytes, fmt)} {fmt.dateTime(row.createdAt)}
restore.mutate({ row, ...options }) } />
{/* Narrow layout: each backup as a self-contained card with the meta line + a flex-wrapping action row, so Download / Restore stay reachable on a phone instead of scrolling off-screen. */}
    {rows.map((row) => (
  • {row.username} {formatBackupType(row.type, t)}

    {t("admin.section.backups.colSize")}:{" "} {formatBytes(row.sizeBytes, fmt)} ·{" "} {fmt.dateTime(row.createdAt)}

    restore.mutate({ row, ...options }) } />
  • ))}

{t("admin.section.backups.retentionLabel", { days: data!.retentionDays, })}

)}
); }