Merge the release into the backup health work and read the real dump health

The readiness model now takes the database dump's state from
getDatabaseBackupHealth, and the dump test reaches the off-host key id
where it moved.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 14:40:31 +01:00
commit e88dcd2c53
99 files changed
+6106 -492

No files matched your search

@@ -54,9 +54,29 @@ vi.mock("@/lib/jobs/offhost-backup", () => ({
probeOffhostLifecycle: async () => ({
state: "missing",
expirationDays: null,
database: { state: "missing", expirationDays: null },
}),
}));
const databaseHealth = {
enabled: true,
directory: "/var/lib/healthlog/backups",
keepNightly: 7,
localDirWritable: true,
offhostConfigured: true,
lastGood: {
at: "2026-09-21T23:31:40.000Z",
fileName: "healthlog-20260921T233000Z-daily.dump",
sizeBytes: 52_428_800,
local: true,
offhost: true,
},
lastFailure: null,
};
vi.mock("@/lib/database-backup/health", () => ({
getDatabaseBackupHealth: async () => databaseHealth,
}));
import { GET } from "../route";
import type { BackupsList } from "@/types/backups";
@@ -166,7 +186,11 @@ describe("GET /api/admin/backups — off-host freshness", () => {
periodHours: 24,
rows: [],
// No bucket to ask on a host that is not configured.
lifecycle: { state: "unknown", expirationDays: null },
lifecycle: {
state: "unknown",
expirationDays: null,
database: { state: "unknown", expirationDays: null },
},
});
});
@@ -229,6 +253,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
expect(body.offhost.lifecycle).toEqual({
state: "missing",
expirationDays: null,
database: { state: "missing", expirationDays: null },
});
expect(body.offhost.pendingDeletions).toEqual({
count: 2,
@@ -285,3 +310,10 @@ describe("GET /api/admin/backups — a run in progress and one a process died un
});
});
});
describe("GET /api/admin/backups — the database dump", () => {
it("carries the dump's health beside the account copies", async () => {
const body = await read();
expect(body.database).toEqual(databaseHealth);
});
});
+10 -4
View File
@@ -14,6 +14,10 @@
* stopped six weeks ago — every row it lists has a perfectly ordinary
* timestamp either way.
*
* The database row comes first in what an operator needs and last in this
* file: the nightly whole-database dump (`getDatabaseBackupHealth`), the
* one copy that rebuilds a lost server, read from its own ledger.
*
* And alongside both, the off-host leg: one row per account saying when this
* host last put that account's encrypted copy in the operator's bucket and how
* stale that is against the nightly schedule. It comes from the ledger the
@@ -31,11 +35,13 @@ import {
} from "@/lib/jobs/job-failures";
import { readBackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
import { getDatabaseBackupHealth } from "@/lib/database-backup/health";
import {
offhostBackupConfigured,
probeOffhostLifecycle,
} from "@/lib/jobs/offhost-backup";
import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue";
import { LIFECYCLE_UNKNOWN } from "@/lib/jobs/offhost-lifecycle";
import {
classifyOffhostBackup,
OFFHOST_BACKUP_PERIOD_HOURS,
@@ -190,16 +196,15 @@ export const GET = apiHandler(async () => {
readBackupPassActivity(prisma, DATA_BACKUP_QUEUE, weeklyRunningSince),
readBackupPassActivity(prisma, OFFHOST_BACKUP_QUEUE, offhostRunningSince),
]);
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
const [purgeCount, oldestPurge, lifecycle, database] = await Promise.all([
prisma.offhostPurgeRequest.count({ where: stillInBucket }),
prisma.offhostPurgeRequest.findFirst({
where: stillInBucket,
orderBy: { requestedAt: "asc" },
select: { requestedAt: true, lastFailure: true },
}),
configured
? probeOffhostLifecycle()
: Promise.resolve({ state: "unknown" as const, expirationDays: null }),
configured ? probeOffhostLifecycle() : Promise.resolve(LIFECYCLE_UNKNOWN),
getDatabaseBackupHealth(now),
]);
const payload: BackupsList = {
@@ -229,6 +234,7 @@ export const GET = apiHandler(async () => {
},
lifecycle,
},
database,
};
return apiSuccess(payload);
+5 -4
View File
@@ -502,8 +502,8 @@ export const GET = apiHandler(async () => {
} satisfies IntegrationViewModel & NightscoutExtras,
{
...publish(dawarichStatus),
// v1.43 — Dawarich, read by the card through `health`. The ledger core
// rides along like on every entry, and goes with the rest in v1.44.
// Dawarich, read by the card through `health`. The ledger core rides
// along like on every entry.
connected: dawarichConfigured,
configured: dawarichConfigured,
health: health["dawarich"],
@@ -524,8 +524,9 @@ interface IntegrationViewModel {
* health-shaped field on this entry (`state`, `lastError`,
* `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`,
* `hasActivityScope`, `planUnavailable`, `scopeReconnect`) is folded into
* it and goes once the settings cards render `health`; no other client
* reads this envelope.
* it. Those stay on the wire, deprecated in the published document; the
* cards render `health`, and only their copied diagnostics still read
* `consecutiveFailuresByKind`. No native client reads this envelope.
*/
health: ConnectionHealth | null;
state: string;