From 3646502a865845873fddcc195201ecdcbfbbc251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 12:52:12 +0100 Subject: [PATCH] feat(admin): show the newest database dump on the backups page GET /api/admin/backups now carries `database`: the newest good dump (when, how big, kept on the server, off-host or both), the newest failed run, whether this server can write its backup directory, and whether off-host copies are configured. The backups card leads with one row built from it, with a notice when the last run failed and when dumps can only go off-host or cannot be taken at all. The route stays an unpublished admin console route, so the OpenAPI document does not change. --- messages/de.json | 15 ++- messages/en.json | 15 ++- messages/es.json | 15 ++- messages/fr.json | 15 ++- messages/it.json | 15 ++- messages/ko.json | 15 ++- messages/nl.json | 15 ++- messages/pl.json | 15 ++- messages/pt-BR.json | 15 ++- .../api/admin/backups/__tests__/route.test.ts | 25 ++++ src/app/api/admin/backups/route.ts | 9 +- src/components/admin/backups-section.tsx | 2 + .../admin/backups/database-dump-row.tsx | 114 ++++++++++++++++++ src/types/backups.ts | 7 ++ 14 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 src/components/admin/backups/database-dump-row.tsx diff --git a/messages/de.json b/messages/de.json index cfe7abcc4..eaf1be7fc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8310,7 +8310,20 @@ "restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)", "restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)", "restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)", - "restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)" + "restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)", + "database": { + "title": "Datenbank-Dump", + "lastGood": "Letzter gültiger Dump {when}, {size}, {where}.", + "whereBoth": "auf diesem Server und extern gespeichert", + "whereLocal": "auf diesem Server gespeichert", + "whereOffhost": "nur extern gespeichert", + "none": "Noch kein Datenbank-Dump. Der erste läuft heute Nacht.", + "failedTitle": "Der letzte Datenbank-Dump ist fehlgeschlagen", + "failedDescription": "{when}: {reason}", + "disabled": "Datenbank-Dumps sind abgeschaltet (DB_BACKUP_ENABLED=false). Sichere die Datenbank auf anderem Weg.", + "noDirectoryOffhost": "Dieser Server kann nicht nach {dir} schreiben. Dumps gehen deshalb nur extern, hier wird keiner aufbewahrt. Binde das Backup-Volume ein.", + "noDirectory": "Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein." + } }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 9e7fa4317..b5656728e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8310,7 +8310,20 @@ "restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)", "restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)", "restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)", - "restoreSkippedEnvironmentLocationCiphertext": "Location of an environment period or day sealed with a key this server does not hold (kept sealed, those days are not refreshed)" + "restoreSkippedEnvironmentLocationCiphertext": "Location of an environment period or day sealed with a key this server does not hold (kept sealed, those days are not refreshed)", + "database": { + "title": "Database dump", + "lastGood": "Last good dump {when}, {size}, {where}.", + "whereBoth": "kept on this server and off-host", + "whereLocal": "kept on this server", + "whereOffhost": "off-host only", + "none": "No database dump yet. The first one runs tonight.", + "failedTitle": "The last database dump failed", + "failedDescription": "{when}: {reason}", + "disabled": "Database dumps are switched off (DB_BACKUP_ENABLED=false). Back the database up another way.", + "noDirectoryOffhost": "This server cannot write to {dir}, so dumps go off-host only and none is kept here. Mount the backups volume.", + "noDirectory": "This server cannot write to {dir} and no off-host bucket is set, so no database dump can be taken. Mount the backups volume." + } }, "danger-zone": { "title": "Danger zone", diff --git a/messages/es.json b/messages/es.json index 55dd09b69..a9c2b3c55 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8310,7 +8310,20 @@ "restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)", "restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)", "restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)", - "restoreSkippedEnvironmentLocationCiphertext": "Ubicación de un periodo o día ambiental cifrada con una clave que este servidor no tiene (se conserva cifrada, esos días no se actualizan)" + "restoreSkippedEnvironmentLocationCiphertext": "Ubicación de un periodo o día ambiental cifrada con una clave que este servidor no tiene (se conserva cifrada, esos días no se actualizan)", + "database": { + "title": "Volcado de la base de datos", + "lastGood": "Último volcado correcto {when}, {size}, {where}.", + "whereBoth": "guardado en este servidor y fuera de él", + "whereLocal": "guardado en este servidor", + "whereOffhost": "solo fuera del servidor", + "none": "Aún no hay ningún volcado de la base de datos. El primero se hace esta noche.", + "failedTitle": "El último volcado de la base de datos falló", + "failedDescription": "{when}: {reason}", + "disabled": "Los volcados de la base de datos están desactivados (DB_BACKUP_ENABLED=false). Haz la copia de la base de datos de otra forma.", + "noDirectoryOffhost": "Este servidor no puede escribir en {dir}, así que los volcados solo se guardan fuera y aquí no queda ninguno. Monta el volumen de copias.", + "noDirectory": "Este servidor no puede escribir en {dir} y no hay almacenamiento externo configurado, así que no se puede hacer ningún volcado. Monta el volumen de copias." + } }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index d74a6043b..2c38c64f1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8310,7 +8310,20 @@ "restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)", "restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)", "restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)", - "restoreSkippedEnvironmentLocationCiphertext": "Lieu d'une période ou d'un jour environnemental chiffré avec une clé que ce serveur ne possède pas (conservé chiffré, ces jours ne sont pas actualisés)" + "restoreSkippedEnvironmentLocationCiphertext": "Lieu d'une période ou d'un jour environnemental chiffré avec une clé que ce serveur ne possède pas (conservé chiffré, ces jours ne sont pas actualisés)", + "database": { + "title": "Dump de la base de données", + "lastGood": "Dernier dump valide {when}, {size}, {where}.", + "whereBoth": "conservé sur ce serveur et hors site", + "whereLocal": "conservé sur ce serveur", + "whereOffhost": "hors site uniquement", + "none": "Aucun dump de la base de données pour l'instant. Le premier a lieu cette nuit.", + "failedTitle": "Le dernier dump de la base de données a échoué", + "failedDescription": "{when} : {reason}", + "disabled": "Les dumps de la base de données sont désactivés (DB_BACKUP_ENABLED=false). Sauvegardez la base de données autrement.", + "noDirectoryOffhost": "Ce serveur ne peut pas écrire dans {dir} : les dumps partent uniquement hors site et aucun n'est conservé ici. Montez le volume de sauvegarde.", + "noDirectory": "Ce serveur ne peut pas écrire dans {dir} et aucun stockage hors site n'est configuré : aucun dump ne peut être fait. Montez le volume de sauvegarde." + } }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index b1de9b105..86af90b9d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8310,7 +8310,20 @@ "restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)", "restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)", "restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)", - "restoreSkippedEnvironmentLocationCiphertext": "Luogo di un periodo o giorno ambientale cifrato con una chiave che questo server non possiede (mantenuto cifrato, quei giorni non vengono aggiornati)" + "restoreSkippedEnvironmentLocationCiphertext": "Luogo di un periodo o giorno ambientale cifrato con una chiave che questo server non possiede (mantenuto cifrato, quei giorni non vengono aggiornati)", + "database": { + "title": "Dump del database", + "lastGood": "Ultimo dump valido {when}, {size}, {where}.", + "whereBoth": "conservato su questo server e fuori sede", + "whereLocal": "conservato su questo server", + "whereOffhost": "solo fuori sede", + "none": "Ancora nessun dump del database. Il primo viene eseguito stanotte.", + "failedTitle": "L'ultimo dump del database non è riuscito", + "failedDescription": "{when}: {reason}", + "disabled": "I dump del database sono disattivati (DB_BACKUP_ENABLED=false). Esegui il backup del database in un altro modo.", + "noDirectoryOffhost": "Questo server non può scrivere in {dir}, quindi i dump vanno solo fuori sede e qui non ne resta nessuno. Monta il volume dei backup.", + "noDirectory": "Questo server non può scrivere in {dir} e non è configurato alcuno storage esterno, quindi non si può fare alcun dump. Monta il volume dei backup." + } }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 3fbdc541b..8325cf886 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8285,7 +8285,20 @@ "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)", "restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)", "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)", - "restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)" + "restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)", + "database": { + "title": "데이터베이스 덤프", + "lastGood": "마지막 정상 덤프 {when}, {size}, {where}.", + "whereBoth": "이 서버와 외부 저장소에 보관됨", + "whereLocal": "이 서버에 보관됨", + "whereOffhost": "외부 저장소에만 있음", + "none": "아직 데이터베이스 덤프가 없습니다. 첫 덤프는 오늘 밤에 실행됩니다.", + "failedTitle": "마지막 데이터베이스 덤프가 실패했습니다", + "failedDescription": "{when}: {reason}", + "disabled": "데이터베이스 덤프가 꺼져 있습니다(DB_BACKUP_ENABLED=false). 다른 방법으로 데이터베이스를 백업하세요.", + "noDirectoryOffhost": "이 서버는 {dir}에 쓸 수 없어 덤프가 외부 저장소로만 가고 여기에는 보관되지 않습니다. 백업 볼륨을 마운트하세요.", + "noDirectory": "이 서버는 {dir}에 쓸 수 없고 외부 저장소도 설정되지 않아 데이터베이스 덤프를 만들 수 없습니다. 백업 볼륨을 마운트하세요." + } }, "danger-zone": { "title": "위험 구역", diff --git a/messages/nl.json b/messages/nl.json index af2efc0a4..9026deedf 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8310,7 +8310,20 @@ "restoreSkippedMoodLabelCiphertext": "Stemmingslabel of groepsnaam versleuteld met een sleutel die deze server niet heeft (behouden zonder naam)", "restoreSkippedVisitCiphertext": "Tekst van bezoek of zorgverlener versleuteld met een sleutel die deze server niet heeft (behouden zonder)", "restoreSkippedVaccinationCiphertext": "Vaccinatienotitie versleuteld met een sleutel die deze server niet heeft (dosis behouden zonder)", - "restoreSkippedEnvironmentLocationCiphertext": "Locatie van een omgevingsperiode of -dag versleuteld met een sleutel die deze server niet heeft (versleuteld behouden, die dagen worden niet bijgewerkt)" + "restoreSkippedEnvironmentLocationCiphertext": "Locatie van een omgevingsperiode of -dag versleuteld met een sleutel die deze server niet heeft (versleuteld behouden, die dagen worden niet bijgewerkt)", + "database": { + "title": "Databasedump", + "lastGood": "Laatste geldige dump {when}, {size}, {where}.", + "whereBoth": "bewaard op deze server en extern", + "whereLocal": "bewaard op deze server", + "whereOffhost": "alleen extern", + "none": "Nog geen databasedump. De eerste wordt vannacht gemaakt.", + "failedTitle": "De laatste databasedump is mislukt", + "failedDescription": "{when}: {reason}", + "disabled": "Databasedumps staan uit (DB_BACKUP_ENABLED=false). Maak op een andere manier een back-up van de database.", + "noDirectoryOffhost": "Deze server kan niet schrijven naar {dir}, dus dumps gaan alleen extern en hier wordt er geen bewaard. Koppel het back-upvolume.", + "noDirectory": "Deze server kan niet schrijven naar {dir} en er is geen externe opslag ingesteld, dus er kan geen databasedump worden gemaakt. Koppel het back-upvolume." + } }, "danger-zone": { "title": "Gevarenzone", diff --git a/messages/pl.json b/messages/pl.json index b0dec38cc..850a5f449 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8310,7 +8310,20 @@ "restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)", "restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)", "restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)", - "restoreSkippedEnvironmentLocationCiphertext": "Lokalizacja okresu lub dnia środowiskowego zaszyfrowana kluczem, którego ten serwer nie ma (zachowana w postaci zaszyfrowanej, te dni nie są odświeżane)" + "restoreSkippedEnvironmentLocationCiphertext": "Lokalizacja okresu lub dnia środowiskowego zaszyfrowana kluczem, którego ten serwer nie ma (zachowana w postaci zaszyfrowanej, te dni nie są odświeżane)", + "database": { + "title": "Zrzut bazy danych", + "lastGood": "Ostatni poprawny zrzut {when}, {size}, {where}.", + "whereBoth": "przechowywany na tym serwerze i poza nim", + "whereLocal": "przechowywany na tym serwerze", + "whereOffhost": "tylko poza serwerem", + "none": "Nie ma jeszcze zrzutu bazy danych. Pierwszy powstanie tej nocy.", + "failedTitle": "Ostatni zrzut bazy danych się nie powiódł", + "failedDescription": "{when}: {reason}", + "disabled": "Zrzuty bazy danych są wyłączone (DB_BACKUP_ENABLED=false). Zrób kopię bazy danych w inny sposób.", + "noDirectoryOffhost": "Ten serwer nie może zapisywać w {dir}, więc zrzuty trafiają tylko poza serwer i żaden nie zostaje tutaj. Podłącz wolumin kopii.", + "noDirectory": "Ten serwer nie może zapisywać w {dir} i nie ustawiono magazynu zewnętrznego, więc nie da się wykonać zrzutu bazy. Podłącz wolumin kopii." + } }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 3137e637c..7343679c5 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8310,7 +8310,20 @@ "restoreSkippedMoodLabelCiphertext": "Etiqueta ou nome de grupo de humor selado com uma chave que este servidor não possui (mantido sem o nome)", "restoreSkippedVisitCiphertext": "Texto de consulta ou profissional selado com uma chave que este servidor não possui (mantido sem ele)", "restoreSkippedVaccinationCiphertext": "Nota de vacinação selada com uma chave que este servidor não possui (dose mantida sem ela)", - "restoreSkippedEnvironmentLocationCiphertext": "Local de um período ou dia de ambiente selado com uma chave que este servidor não possui (mantido selado, esses dias não são atualizados)" + "restoreSkippedEnvironmentLocationCiphertext": "Local de um período ou dia de ambiente selado com uma chave que este servidor não possui (mantido selado, esses dias não são atualizados)", + "database": { + "title": "Dump do banco de dados", + "lastGood": "Último dump válido {when}, {size}, {where}.", + "whereBoth": "guardado neste servidor e fora dele", + "whereLocal": "guardado neste servidor", + "whereOffhost": "somente fora do servidor", + "none": "Ainda não há dump do banco de dados. O primeiro é feito esta noite.", + "failedTitle": "O último dump do banco de dados falhou", + "failedDescription": "{when}: {reason}", + "disabled": "Os dumps do banco de dados estão desligados (DB_BACKUP_ENABLED=false). Faça o backup do banco de outra forma.", + "noDirectoryOffhost": "Este servidor não consegue gravar em {dir}, então os dumps vão só para fora e nenhum fica aqui. Monte o volume de backups.", + "noDirectory": "Este servidor não consegue gravar em {dir} e não há armazenamento externo configurado, então nenhum dump pode ser feito. Monte o volume de backups." + } }, "danger-zone": { "title": "Zona de perigo", diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts index 25c61fb3b..ec407f99f 100644 --- a/src/app/api/admin/backups/__tests__/route.test.ts +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -57,6 +57,24 @@ vi.mock("@/lib/jobs/offhost-backup", () => ({ }), })); +const databaseHealth = { + enabled: true, + directory: "/var/lib/healthlog/backups", + 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"; @@ -285,3 +303,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); + }); +}); diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 495777c14..c9bd278cd 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -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,6 +35,7 @@ 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, @@ -190,7 +195,7 @@ 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, @@ -200,6 +205,7 @@ export const GET = apiHandler(async () => { configured ? probeOffhostLifecycle() : Promise.resolve({ state: "unknown" as const, expirationDays: null }), + getDatabaseBackupHealth(now), ]); const payload: BackupsList = { @@ -229,6 +235,7 @@ export const GET = apiHandler(async () => { }, lifecycle, }, + database, }; return apiSuccess(payload); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index d8dcb62c2..12355b7e2 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -28,6 +28,7 @@ import type { BackupRow, BackupsList } from "@/types/backups"; import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status"; import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows"; +import { DatabaseDumpRow } from "./backups/database-dump-row"; import { BackupUploadCard } from "./backups/backup-upload-card"; import { RestoreRefusalNotice, @@ -110,6 +111,7 @@ export function BackupsSection() { } /> + {data?.database ? : null} {data?.schedule ? ( ) : null} diff --git a/src/components/admin/backups/database-dump-row.tsx b/src/components/admin/backups/database-dump-row.tsx new file mode 100644 index 000000000..eb1711c43 --- /dev/null +++ b/src/components/admin/backups/database-dump-row.tsx @@ -0,0 +1,114 @@ +"use client"; + +/** + * The nightly whole-database dump, as one row on the backups card. + * + * The account copies below it restore one account on this server; the dump is + * the copy that rebuilds the server. So the row leads the card and says three + * things: when the newest good dump was taken and where it is, whether the + * newest run failed, and whether this server can keep a dump at all. + */ +import { AlertTriangle, HardDrive } from "lucide-react"; + +import { SettingsInfoTile } from "@/components/settings/_info-tile"; +import { formatBytes } from "@/lib/format/bytes"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { DatabaseBackupHealth } from "@/lib/database-backup/health"; + +function whereKept( + good: NonNullable, + t: ReturnType["t"], +) { + if (good.local && good.offhost) { + return t("admin.section.backups.database.whereBoth"); + } + if (good.offhost) return t("admin.section.backups.database.whereOffhost"); + return t("admin.section.backups.database.whereLocal"); +} + +export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + const good = health.lastGood; + const failure = health.lastFailure; + const failedSinceGood = + failure !== null && (good === null || failure.at > good.at); + + let setupProblem: string | null = null; + if (!health.enabled) { + setupProblem = t("admin.section.backups.database.disabled"); + } else if (!health.localDirWritable) { + setupProblem = health.offhostConfigured + ? t("admin.section.backups.database.noDirectoryOffhost", { + dir: health.directory, + }) + : t("admin.section.backups.database.noDirectory", { + dir: health.directory, + }); + } + + return ( +
+
+
+ + {failedSinceGood && failure ? ( +
+
+
+
+ ) : null} + + {setupProblem ? ( + + {setupProblem} + + ) : null} +
+ ); +} diff --git a/src/types/backups.ts b/src/types/backups.ts index f3c042999..5f2a28e2c 100644 --- a/src/types/backups.ts +++ b/src/types/backups.ts @@ -9,6 +9,7 @@ * both sides a route-handler-independent shared type home; the route * keeps owning the HTTP contract, the component keeps owning the UI. */ +import type { DatabaseBackupHealth } from "@/lib/database-backup/health"; import type { BackupPassActivity } from "@/lib/jobs/backup-pass-attempts"; import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status"; import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness"; @@ -101,6 +102,12 @@ export interface BackupsList { * the operator's bucket, and those two fail independently. */ offhost: OffhostBackupOverview; + /** + * The nightly whole-database dump, the copy that rebuilds a lost server: + * the newest good dump (kept here, off-host, or both), the newest failed + * run, and whether this server can keep dumps at all. + */ + database: DatabaseBackupHealth; /** * Soft retention hint — the worker is configured for weekly backups * (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and