feat(admin): judge every backup signal and push changes to admins

Every backup failure was a pull, on a page nobody opens until the day it
matters: the weekly copy stopped for 46 days and showed an ordinary row,
a failed monthly drill was on the failing-jobs card three days in thirty
(job-failures.ts reads 72 hours), and an off-host bucket that was never
set up showed only on the backups page.

One readiness model (src/lib/recovery/readiness-model.ts) judges six
signals as ok, attention or failing, with a reason code, the time it is
about and where to fix it:

- database dump: failing once the newest good dump is older than 36 h
  while dumps are on, or when it can be kept nowhere; read through a
  provider that reports "not available" until the dump lands;
- weekly account copies: worst account decides, attention past 9 days,
  failing past 14; an account without a copy ages from its creation;
- off-host copies: attention when any account is two nights behind;
- restore drill: failing until a later drill passes, with no time
  window; the drill now keeps its verdict in restore_drill_outcomes;
- off-host setup: attention 7 days after the first admin exists;
- recovery kit: attention when a needed key is not confirmed, by id,
  7 days after setup or as soon as a confirmed kit is out of date.

A "Recovery readiness" card shows them on the admin overview and the
backups page. An hourly job (recovery-readiness, :33) sends each change
to every admin through the localised admin path, under a new
admin-only BACKUP_HEALTH event that the channel preferences route: once
when a signal turns to attention or failing, once a day while it stays
failing, once when it recovers. Each notice is claimed with a
compare-and-set before it is sent, so two workers never send the same
one, and nothing is claimed while no admin exists.

The backups page calls the weekly copy stale at the same instant as the
model (past nine days rather than past ten).
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:32:43 +01:00
1 parent d3c672cc3f
commit 7dab9cc5e9
40 files changed
+3386 -37

No files matched your search

@@ -53,7 +53,7 @@ vi.mock("@tanstack/react-query", () => ({
schedule: {
lastSuccessAt: "2026-09-01T03:00:00.000Z",
lastSuccessAgeDays: 2,
staleAfterDays: 10,
staleAfterDays: 9,
stale: false,
lastRun: null,
lastRunFailed: false,
@@ -6,7 +6,7 @@ import en from "../../../../messages/en.json";
const healthySchedule = {
lastSuccessAt: "2026-09-01T03:00:00.000Z",
lastSuccessAgeDays: 2,
staleAfterDays: 10,
staleAfterDays: 9,
stale: false,
lastRun: {
at: "2026-09-01T03:00:12.000Z",
@@ -0,0 +1,154 @@
/**
* The recovery readiness card.
*
* 1. every signal renders as its own row with the server's state, its
* sentence and its time;
* 2. a row that needs work links to where it is fixed, unless that is the
* page already open; an ok or inactive row carries no link;
* 3. a grace period shows its due date rather than a time of an event;
* 4. a failed drill shows its reason with no time window: a failure months
* old is still on the card;
* 5. the overall badge carries the worst state; the copy resolves in German.
*
* Mutation check: show the fix link on every row and case 2 goes red; drop
* the `detail` line and case 4 goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { RecoveryReadinessCard } from "@/components/admin/recovery-readiness-card";
import type {
ReadinessSignal,
RecoveryReadiness,
} from "@/components/admin/use-recovery-readiness";
import { READINESS_FIX_PATHS } from "@/lib/recovery/readiness-model";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
const pathname = vi.hoisted(() => ({ current: "/admin" }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => pathname.current,
}));
function sig(
id: ReadinessSignal["id"],
state: ReadinessSignal["state"],
reason: string,
over: Partial<ReadinessSignal> = {},
): ReadinessSignal {
return {
id,
state,
reason,
at: null,
params: {},
detail: null,
fixPath: READINESS_FIX_PATHS[id],
...over,
};
}
const READINESS: RecoveryReadiness = {
state: "failing",
evaluatedAt: "2026-10-25T12:00:00.000Z",
signals: [
sig("database_dump", "inactive", "not_available"),
sig("weekly_copy", "attention", "stale", {
at: "2026-10-14T03:00:00.000Z",
params: { days: 11, accounts: 2 },
}),
sig("offhost_copies", "ok", "fresh", { at: "2026-10-25T02:40:00.000Z" }),
sig("restore_drill", "failing", "failed", {
at: "2026-07-01T04:20:00.000Z",
params: { failed: 1, checked: 4 },
detail: "1 of 4 backup objects failed. Restore drill: bad tag",
}),
sig("offhost_setup", "ok", "configured"),
sig("recovery_kit", "ok", "not_confirmed_grace", {
at: "2026-10-30T09:00:00.000Z",
params: { keys: "v2, API_TOKEN_HMAC_KEY", count: 2 },
}),
],
};
function render(readiness: RecoveryReadiness, locale: "en" | "de" = "en") {
const client = new QueryClient();
client.setQueryData(queryKeys.adminRecoveryReadiness(), readiness);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>
<RecoveryReadinessCard />
</I18nProvider>
</QueryClientProvider>,
);
}
/** The markup of one signal's row. */
function row(html: string, id: string): string {
const start = html.indexOf(`data-testid="recovery-signal-${id}"`);
expect(start).toBeGreaterThan(-1);
const end = html.indexOf("</li>", start);
return html.slice(start, end);
}
describe("RecoveryReadinessCard", () => {
it("renders every signal with its state and sentence", () => {
pathname.current = "/admin";
const html = render(READINESS);
for (const s of READINESS.signals) {
expect(row(html, s.id)).toContain(`data-state="${s.state}"`);
}
expect(row(html, "weekly_copy")).toContain(
"The oldest weekly copy is 11 days old. Accounts behind: 2.",
);
expect(row(html, "database_dump")).toContain(
"This version does not schedule a database dump.",
);
});
it("links a row that needs work to where it is fixed, and nothing else", () => {
pathname.current = "/admin";
const html = render(READINESS);
expect(row(html, "weekly_copy")).toContain('href="/admin/backups"');
expect(row(html, "restore_drill")).toContain('href="/admin/backups"');
expect(row(html, "recovery_kit")).toContain('href="/admin/encryption"');
expect(row(html, "offhost_copies")).not.toContain("href=");
expect(row(html, "database_dump")).not.toContain("href=");
});
it("does not link to the page already open", () => {
pathname.current = "/admin/backups";
const html = render(READINESS);
expect(row(html, "weekly_copy")).not.toContain("href=");
expect(row(html, "recovery_kit")).toContain('href="/admin/encryption"');
});
it("shows a grace period's due date", () => {
pathname.current = "/admin";
expect(row(render(READINESS), "recovery_kit")).toContain("Due by ");
});
it("keeps a months-old drill failure on the card with its reason", () => {
pathname.current = "/admin";
const drill = row(render(READINESS), "restore_drill");
expect(drill).toContain(
"The last restore drill failed: 1 of 4 copies could not be read back.",
);
expect(drill).toContain("Restore drill: bad tag");
expect(drill).toContain("Last drill: ");
});
it("carries the worst state on the badge, and speaks German", () => {
pathname.current = "/admin";
const html = render(READINESS);
expect(html).toContain('data-testid="recovery-readiness-state"');
expect(html).toContain('data-state="failing"');
expect(render({ ...READINESS, state: "ok" })).toContain(">Ready<");
expect(render(READINESS, "de")).toContain("Wiederherstellbarkeit");
});
});
@@ -0,0 +1,208 @@
"use client";
/**
* `<RecoveryReadinessCard>` — every backup signal on one card: the database
* dump, the weekly account copies, the off-host copies, the last restore
* drill, the off-host setup and the recovery kit. Each row says its verdict,
* the time it is about and where to fix it.
*
* No time window anywhere: a failed drill stays on this card until a later
* drill passes, where the failing-jobs card forgot it after 72 hours. The
* verdicts are the server's (`src/lib/recovery/readiness-model.ts`), the same
* ones the hourly evaluation pushes to every admin, so the card and the push
* cannot disagree.
*
* Shown on the admin overview and on the Backups page. A row whose fix lives
* on the page already open carries no link to itself.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
AlertTriangle,
CheckCircle2,
Clock,
LifeBuoy,
Loader2,
MinusCircle,
XCircle,
} from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
useRecoveryReadiness,
type ReadinessSignal,
type ReadinessState,
type RecoveryReadiness,
} from "./use-recovery-readiness";
const STATE_ICON: Record<
ReadinessState,
React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
> = {
ok: CheckCircle2,
attention: AlertTriangle,
failing: XCircle,
inactive: MinusCircle,
};
const STATE_TONE: Record<ReadinessState, string> = {
ok: "text-success",
attention: "text-warning",
failing: "text-destructive",
inactive: "text-muted-foreground",
};
/** A grace period is ok for the verdict, but still a step for the admin. */
const isGrace = (signal: ReadinessSignal) => signal.reason.endsWith("_grace");
function OverallBadge({
state,
}: {
state: Exclude<ReadinessState, "inactive">;
}) {
const { t } = useTranslations();
const label = {
ok: t("admin.recoveryReadiness.state.ok"),
attention: t("admin.recoveryReadiness.state.attention"),
failing: t("admin.recoveryReadiness.state.failing"),
}[state];
return (
<Badge
variant="outline"
data-testid="recovery-readiness-state"
data-state={state}
className={cn(
state === "attention" && "border-warning/40 text-warning",
state === "failing" && "border-destructive/40 text-destructive",
)}
>
{label}
</Badge>
);
}
function SignalRow({
signal,
currentPath,
}: {
signal: ReadinessSignal;
currentPath: string | null;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const grace = isGrace(signal);
const Icon = grace ? Clock : STATE_ICON[signal.state];
const tone = grace ? "text-info" : STATE_TONE[signal.state];
const date = signal.at ? fmt.dateTime(new Date(signal.at)) : null;
const showFix =
signal.state !== "inactive" &&
(signal.state !== "ok" || grace) &&
signal.fixPath !== currentPath;
return (
<li
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
data-testid={`recovery-signal-${signal.id}`}
data-state={signal.state}
>
<Icon className={cn("mt-0.5 size-4 shrink-0", tone)} aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">
{t(`admin.recoveryReadiness.signal.${signal.id}`)}
</p>
<p className="text-sm">
{t(
`admin.recoveryReadiness.reason.${signal.id}.${signal.reason}`,
signal.params,
)}
</p>
{date ? (
<p className="text-muted-foreground text-xs">
{grace
? t("admin.recoveryReadiness.dueBy", { date })
: t(`admin.recoveryReadiness.at.${signal.id}`, { date })}
</p>
) : null}
{signal.detail && signal.state !== "ok" ? (
<p className="text-muted-foreground text-xs break-words">
{signal.detail}
</p>
) : null}
</div>
{showFix ? (
<InlineAction asChild size="sm" className="shrink-0 font-medium">
<Link href={signal.fixPath}>
{t(`admin.recoveryReadiness.fix.${signal.id}`)}
</Link>
</InlineAction>
) : null}
</li>
);
}
/** The list itself, for a readiness that has loaded. */
export function RecoveryReadinessList({
readiness,
currentPath,
}: {
readiness: RecoveryReadiness;
currentPath: string | null;
}) {
return (
<ul className="divide-border divide-y" data-testid="recovery-readiness">
{readiness.signals.map((signal) => (
<SignalRow key={signal.id} signal={signal} currentPath={currentPath} />
))}
</ul>
);
}
export function RecoveryReadinessCard() {
const { t } = useTranslations();
const currentPath = usePathname();
const { data, isError, error, refetch } = useRecoveryReadiness();
return (
<SettingsCard
as="section"
aria-labelledby="recovery-readiness-heading"
data-testid="recovery-readiness-card"
>
<SettingsCardHeader
icon={LifeBuoy}
titleId="recovery-readiness-heading"
title={t("admin.recoveryReadiness.title")}
description={t("admin.recoveryReadiness.description")}
status={data ? <OverallBadge state={data.state} /> : null}
/>
{data ? (
<RecoveryReadinessList readiness={data} currentPath={currentPath} />
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.recoveryReadiness.loadError")}
onRetry={() => void refetch()}
/>
) : (
<div className="flex items-center gap-2">
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">
{t("admin.recoveryReadiness.loading")}
</span>
</div>
)}
</SettingsCard>
);
}
@@ -0,0 +1,24 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { RecoveryReadiness } from "@/lib/recovery/readiness-model";
export type {
ReadinessSignal,
ReadinessState,
RecoveryReadiness,
} from "@/lib/recovery/readiness-model";
/** `GET /api/admin/recovery-readiness`; admin-only and cookie-only. */
export function useRecoveryReadiness() {
return useQuery({
queryKey: queryKeys.adminRecoveryReadiness(),
queryFn: () =>
apiGet<RecoveryReadiness>("/api/admin/recovery-readiness", {
credentials: "include",
}),
staleTime: 60_000,
});
}