diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 2b6fe2af9..4130a2277 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -215,6 +215,42 @@ A run where SOME account got a copy still succeeds, with the rest counted in `offhost_backup_failed`. Failing the whole queue over one account's object would re-upload everybody's on every retry. +### Which account has no copy + +The counts above say how many accounts were uploaded, never which. An account +whose object has failed every night for a month reads as `99 uploaded, 1 +failed` each time, which looks like weather. + +**Admin console → Backups → Off-host copies** answers it per account: when this +host last put that account's encrypted copy in the bucket, how big it was, and +how that compares with the nightly schedule. **Fresh** is inside one run, +**due** means one run produced nothing for that account, **stale** means two +did not, **never** means a run walked the account and no object has ever +landed for it, and **no record yet** means no run has recorded the account at +all. On a host without `BACKUP_S3_*` the card says off-host backup is not +configured and stops there. + +The ledger starts empty on the release that adds it, so on a host that has +been uploading nightly for months every account reads **no record yet** until +the first run after the upgrade. That is the ledger having no history, not the +bucket being empty — which is why it is a separate word from **never**, and +why the row says so rather than claiming nothing has ever reached the bucket. + +Each verdict carries six hours of slack on top of the schedule's own 24, and +that slack is load-bearing on two nights. The cron runs at 02:30 Europe/Berlin, +so on the DST fall-back Sunday two consecutive runs are 25 hours apart; and the +timestamp is when _that account's_ object landed, so on a cohort walked one +account at a time an account reached later tonight than last night drifts by +hours. Without the slack a healthy host would paint its whole cohort **due** +once a year. + +The verdicts come from a ledger the worker writes when the object lands +(`offhost_backup_state`), not from a listing of the bucket. Keep the worker's +grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card +still tells the truth. A row that goes stale while the run reports success is +that account's object being refused, and the reason is in the run's +`offhost_backup_failures` meta. + ## The weekly in-database backup (`data-backup`) Separate from the off-host job above, and easy to confuse with it. A second diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 6700f779e..065ed1100 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -143,6 +143,27 @@ itself still works: it walks the journey against an in-process instance once per leg with that leg broken, and fails if any of them comes back green. +## Error reporting + +HealthLog ships no default error-reporting target and never will: an +operator's crash reports must not leave their host because nobody told them +not to. Reporting starts only when someone types a DSN into **Admin → +Integrations → GlitchTip**, and that card states plainly whether reports are +going anywhere and which host they go to. + +For a maintainer-operated instance, set the DSN there after the first deploy — +it lives in the database, not in the environment, so it survives an image +upgrade and does not need a compose change. + +The badge on that card tracks delivery, not intent. A DSN that is set and +parses is not the same fact as reports arriving: a wrong public key, a wrong +project id and a host that stopped answering all parse. So the card is green +only while a report has actually left this host inside the last fortnight, +amber while a target is set and nothing has come back from it, and it names the +reason underneath when the last attempt was refused. Press **Test** to prove +the target on demand — that send is recorded like any other, so the badge +answers immediately. + ## Verify the image signature Every release image (the multi-arch manifest **index**, covering both diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index febb55d52..63a0d5fb0 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -707,6 +707,51 @@ async function installA11yMocks(page: Page) { }, ], retentionDays: 30, + // The off-host card, with one row in each state — the scan has to reach + // the status washes, and a card that paints only its header proves + // nothing about the rows an operator actually reads. + offhost: { + configured: true, + periodHours: 24, + rows: [ + { + userId: "a11y-user", + username: "a11y-backup-user", + lastAttemptAt: "2026-07-20T03:00:00.000Z", + lastSuccessAt: "2026-07-20T03:00:00.000Z", + sizeBytes: 4096, + ageHours: 3, + freshness: "fresh", + }, + { + userId: "a11y-user-2", + username: "a11y-backup-user-2", + lastAttemptAt: "2026-07-14T03:00:00.000Z", + lastSuccessAt: "2026-07-14T03:00:00.000Z", + sizeBytes: 2048, + ageHours: 150, + freshness: "stale", + }, + { + userId: "a11y-user-3", + username: "a11y-backup-user-3", + lastAttemptAt: "2026-07-20T03:00:00.000Z", + lastSuccessAt: null, + sizeBytes: null, + ageHours: null, + freshness: "never", + }, + { + userId: "a11y-user-4", + username: "a11y-backup-user-4", + lastAttemptAt: null, + lastSuccessAt: null, + sizeBytes: null, + ageHours: null, + freshness: "unknown", + }, + ], + }, }), ); } diff --git a/messages/de.json b/messages/de.json index fca6667b9..b6a0d292c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6597,6 +6597,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip Environment", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Fehlerberichte gehen an {host}.", + "glitchtipTargetOff": "Fehlerberichte bleiben auf diesem Host. Es wird nichts verschickt.", + "glitchtipTargetNoTarget": "Fehler-Tracking ist an, aber kein Ziel gesetzt, also wird nichts verschickt.", + "glitchtipUnproven": "Unbestätigt", + "glitchtipDeliveryUnproven": "Ein Ziel ist gesetzt, aber noch kein Bericht hat diesen Host verlassen. Drücke Test, um zu sehen, ob es einen annimmt.", + "glitchtipDeliveryStale": "In den letzten {hours} h hat kein Bericht diesen Host verlassen. Drücke Test, um zu prüfen, ob das Ziel noch annimmt.", + "glitchtipDeliveryFailed": "Der letzte Bericht kam nicht durch ({reason}).", "webPushVapidDescription": "Konfiguriere VAPID-Schlüssel und Subject direkt hier, damit Browser-Push ohne ENV-Variablen funktioniert.", "webPushVapidPublicKey": "VAPID Public Key", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7012,7 +7019,23 @@ "restoreMissingDocuments": "Dokumente", "restoreMissingWorkouts": "Workouts", "restoreMissingMentalHealth": "Psychische Fragebögen", - "restoreMissingConsent": "Einwilligungsnachweise" + "restoreMissingConsent": "Einwilligungsnachweise", + "offhost": { + "title": "Kopien außer Haus", + "description": "Wann dieser Host zuletzt die verschlüsselte Kopie jedes Kontos in deinen Bucket gelegt hat.", + "loadError": "Der Status außer Haus konnte nicht geladen werden.", + "notConfiguredTitle": "Sicherung außer Haus ist nicht eingerichtet", + "notConfiguredDescription": "Es sind weder Bucket noch Schlüssel gesetzt, also verlässt nichts diesen Host. Setze BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY und BACKUP_ENCRYPTION_KEY, um den nächtlichen Upload einzuschalten.", + "stateFresh": "Frisch", + "stateDue": "Fällig", + "stateStale": "Veraltet", + "stateNever": "Nie", + "stateUnknown": "Noch kein Eintrag", + "neverDetail": "Für dieses Konto ist nichts angekommen. Zuletzt geprüft {when}.", + "unknownDetail": "Noch kein nächtlicher Lauf hat dieses Konto erfasst.", + "age": "vor {hours} h", + "scheduleNote": "Der Upload läuft alle {hours} h. Frisch heißt innerhalb eines Laufs, fällig heißt ein Lauf blieb ohne Ergebnis, veraltet heißt zwei." + } }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 5891d7ddf..2b0fd25c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6597,6 +6597,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip environment", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Error reports go to {host}.", + "glitchtipTargetOff": "Error reports stay on this host. Nothing is sent anywhere.", + "glitchtipTargetNoTarget": "Error tracking is on, but no target is set, so nothing is sent.", + "glitchtipUnproven": "Unproven", + "glitchtipDeliveryUnproven": "A target is set, but no report has left this host yet. Press Test to find out whether it accepts one.", + "glitchtipDeliveryStale": "No report has left this host in the last {hours} h. Press Test to check the target still accepts one.", + "glitchtipDeliveryFailed": "The last report did not get through ({reason}).", "webPushVapidDescription": "Configure VAPID keys and subject here so browser push works without environment variables.", "webPushVapidPublicKey": "VAPID public key", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7012,7 +7019,23 @@ "restoreMissingDocuments": "Documents", "restoreMissingWorkouts": "Workouts", "restoreMissingMentalHealth": "Mental-health screeners", - "restoreMissingConsent": "Consent receipts" + "restoreMissingConsent": "Consent receipts", + "offhost": { + "title": "Off-host copies", + "description": "When this host last put each account's encrypted copy in your bucket.", + "loadError": "Could not load the off-host status.", + "notConfiguredTitle": "Off-host backup is not configured", + "notConfiguredDescription": "No bucket and no key are set, so nothing leaves this host. Set BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY and BACKUP_ENCRYPTION_KEY to turn the nightly upload on.", + "stateFresh": "Fresh", + "stateDue": "Due", + "stateStale": "Stale", + "stateNever": "Never", + "stateUnknown": "No record yet", + "neverDetail": "Nothing has landed for this account. Last checked {when}.", + "unknownDetail": "No nightly run has recorded this account yet.", + "age": "{hours} h ago", + "scheduleNote": "The upload runs every {hours} h. Fresh means inside one run, due means one run produced nothing, stale means two did not." + } }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 990f5c414..bb58351cd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6597,6 +6597,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Entorno de GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Los informes de errores van a {host}.", + "glitchtipTargetOff": "Los informes de errores se quedan en este host. No se envía nada.", + "glitchtipTargetNoTarget": "El seguimiento de errores está activado, pero no hay destino, así que no se envía nada.", + "glitchtipUnproven": "Sin confirmar", + "glitchtipDeliveryUnproven": "Hay un destino definido, pero ningún informe ha salido aún de este host. Pulsa Probar para ver si lo acepta.", + "glitchtipDeliveryStale": "Ningún informe ha salido de este host en las últimas {hours} h. Pulsa Probar para comprobar que el destino sigue aceptándolos.", + "glitchtipDeliveryFailed": "El último informe no llegó ({reason}).", "webPushVapidDescription": "Configura aquí las claves VAPID y el subject para que el push del navegador funcione sin variables de entorno.", "webPushVapidPublicKey": "Clave pública VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7012,7 +7019,23 @@ "restoreMissingDocuments": "Documentos", "restoreMissingWorkouts": "Entrenamientos", "restoreMissingMentalHealth": "Cuestionarios de salud mental", - "restoreMissingConsent": "Comprobantes de consentimiento" + "restoreMissingConsent": "Comprobantes de consentimiento", + "offhost": { + "title": "Copias fuera del host", + "description": "Cuándo puso este host por última vez la copia cifrada de cada cuenta en tu bucket.", + "loadError": "No se pudo cargar el estado fuera del host.", + "notConfiguredTitle": "La copia fuera del host no está configurada", + "notConfiguredDescription": "No hay bucket ni clave definidos, así que nada sale de este host. Define BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY y BACKUP_ENCRYPTION_KEY para activar la subida nocturna.", + "stateFresh": "Reciente", + "stateDue": "Pendiente", + "stateStale": "Obsoleta", + "stateNever": "Nunca", + "stateUnknown": "Sin registro aún", + "neverDetail": "No ha llegado nada para esta cuenta. Última comprobación {when}.", + "unknownDetail": "Ninguna ejecución nocturna ha registrado esta cuenta todavía.", + "age": "hace {hours} h", + "scheduleNote": "La subida se ejecuta cada {hours} h. Reciente significa dentro de una ejecución, pendiente que una no produjo nada, obsoleta que fueron dos." + } }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index eee2b3cbd..50b11dad6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6597,6 +6597,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Environnement GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Les rapports d'erreur vont vers {host}.", + "glitchtipTargetOff": "Les rapports d'erreur restent sur cet hôte. Rien n'est envoyé.", + "glitchtipTargetNoTarget": "Le suivi des erreurs est activé, mais aucune cible n'est définie, donc rien n'est envoyé.", + "glitchtipUnproven": "Non confirmé", + "glitchtipDeliveryUnproven": "Une cible est définie, mais aucun rapport n'a encore quitté cet hôte. Appuyez sur Tester pour voir si elle en accepte un.", + "glitchtipDeliveryStale": "Aucun rapport n'a quitté cet hôte depuis {hours} h. Appuyez sur Tester pour vérifier que la cible en accepte toujours.", + "glitchtipDeliveryFailed": "Le dernier rapport n'est pas passé ({reason}).", "webPushVapidDescription": "Configurez ici les clés VAPID et le subject pour que le push du navigateur fonctionne sans variables d'environnement.", "webPushVapidPublicKey": "Clé publique VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7012,7 +7019,23 @@ "restoreMissingDocuments": "Documents", "restoreMissingWorkouts": "Séances", "restoreMissingMentalHealth": "Questionnaires de santé mentale", - "restoreMissingConsent": "Preuves de consentement" + "restoreMissingConsent": "Preuves de consentement", + "offhost": { + "title": "Copies hors hôte", + "description": "Quand cet hôte a déposé pour la dernière fois la copie chiffrée de chaque compte dans votre bucket.", + "loadError": "Impossible de charger l'état hors hôte.", + "notConfiguredTitle": "La sauvegarde hors hôte n'est pas configurée", + "notConfiguredDescription": "Aucun bucket ni clé ne sont définis, donc rien ne quitte cet hôte. Définissez BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY et BACKUP_ENCRYPTION_KEY pour activer l'envoi nocturne.", + "stateFresh": "Récente", + "stateDue": "Attendue", + "stateStale": "Périmée", + "stateNever": "Jamais", + "stateUnknown": "Pas encore d'enregistrement", + "neverDetail": "Rien n'est arrivé pour ce compte. Dernière vérification {when}.", + "unknownDetail": "Aucun cycle nocturne n'a encore enregistré ce compte.", + "age": "il y a {hours} h", + "scheduleNote": "L'envoi tourne toutes les {hours} h. Récente signifie dans un cycle, attendue qu'un cycle n'a rien produit, périmée que deux n'ont rien produit." + } }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index f550c93a0..3b2d11d01 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6597,6 +6597,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Ambiente GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Le segnalazioni di errore vanno a {host}.", + "glitchtipTargetOff": "Le segnalazioni di errore restano su questo host. Non viene inviato nulla.", + "glitchtipTargetNoTarget": "Il tracciamento degli errori è attivo, ma non c'è una destinazione, quindi non viene inviato nulla.", + "glitchtipUnproven": "Non confermato", + "glitchtipDeliveryUnproven": "Una destinazione è impostata, ma nessuna segnalazione ha ancora lasciato questo host. Premi Prova per vedere se ne accetta una.", + "glitchtipDeliveryStale": "Nessuna segnalazione ha lasciato questo host nelle ultime {hours} h. Premi Prova per verificare che la destinazione le accetti ancora.", + "glitchtipDeliveryFailed": "L'ultima segnalazione non è arrivata ({reason}).", "webPushVapidDescription": "Configura qui le chiavi VAPID e il subject affinché il push del browser funzioni senza variabili d'ambiente.", "webPushVapidPublicKey": "Chiave pubblica VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7012,7 +7019,23 @@ "restoreMissingDocuments": "Documenti", "restoreMissingWorkouts": "Allenamenti", "restoreMissingMentalHealth": "Questionari sulla salute mentale", - "restoreMissingConsent": "Ricevute di consenso" + "restoreMissingConsent": "Ricevute di consenso", + "offhost": { + "title": "Copie fuori host", + "description": "Quando questo host ha messo per l'ultima volta la copia cifrata di ogni account nel tuo bucket.", + "loadError": "Impossibile caricare lo stato fuori host.", + "notConfiguredTitle": "Il backup fuori host non è configurato", + "notConfiguredDescription": "Non sono impostati né bucket né chiave, quindi nulla lascia questo host. Imposta BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY e BACKUP_ENCRYPTION_KEY per attivare il caricamento notturno.", + "stateFresh": "Recente", + "stateDue": "Attesa", + "stateStale": "Obsoleta", + "stateNever": "Mai", + "stateUnknown": "Nessuna registrazione", + "neverDetail": "Per questo account non è arrivato nulla. Ultimo controllo {when}.", + "unknownDetail": "Nessun giro notturno ha ancora registrato questo account.", + "age": "{hours} h fa", + "scheduleNote": "Il caricamento gira ogni {hours} h. Recente significa entro un giro, attesa che un giro non ha prodotto nulla, obsoleta che non lo hanno fatto due." + } }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 641082b67..e242f2eb3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6577,6 +6577,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip 환경", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "오류 보고서는 {host}(으)로 전송됩니다.", + "glitchtipTargetOff": "오류 보고서는 이 호스트에 남습니다. 아무것도 전송되지 않습니다.", + "glitchtipTargetNoTarget": "오류 추적은 켜져 있지만 대상이 없어 아무것도 전송되지 않습니다.", + "glitchtipUnproven": "미확인", + "glitchtipDeliveryUnproven": "대상이 설정되어 있지만 아직 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 수락하는지 확인하세요.", + "glitchtipDeliveryStale": "지난 {hours}시간 동안 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 대상이 여전히 수락하는지 확인하세요.", + "glitchtipDeliveryFailed": "마지막 보고서가 전달되지 않았습니다 ({reason}).", "webPushVapidDescription": "환경 변수 없이 브라우저 푸시가 동작하도록 VAPID 키와 subject를 여기서 설정하세요.", "webPushVapidPublicKey": "VAPID 공개 키", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -6992,7 +6999,23 @@ "restoreMissingDocuments": "문서", "restoreMissingWorkouts": "운동", "restoreMissingMentalHealth": "정신 건강 선별 검사", - "restoreMissingConsent": "동의 기록" + "restoreMissingConsent": "동의 기록", + "offhost": { + "title": "외부 보관 사본", + "description": "이 호스트가 각 계정의 암호화 사본을 버킷에 마지막으로 올린 시점입니다.", + "loadError": "외부 보관 상태를 불러오지 못했습니다.", + "notConfiguredTitle": "외부 보관 백업이 설정되지 않았습니다", + "notConfiguredDescription": "버킷과 키가 모두 설정되지 않아 이 호스트를 벗어나는 것이 없습니다. 야간 업로드를 켜려면 BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY, BACKUP_ENCRYPTION_KEY를 설정하세요.", + "stateFresh": "최신", + "stateDue": "지연", + "stateStale": "오래됨", + "stateNever": "없음", + "stateUnknown": "기록 없음", + "neverDetail": "이 계정으로 도착한 것이 없습니다. 마지막 확인 {when}.", + "unknownDetail": "아직 어떤 야간 실행도 이 계정을 기록하지 않았습니다.", + "age": "{hours}시간 전", + "scheduleNote": "업로드는 {hours}시간마다 실행됩니다. 최신은 한 번의 실행 이내, 지연은 한 번이 아무것도 만들지 못한 상태, 오래됨은 두 번이 그런 상태입니다." + } }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index 1caaad0a3..1dbd67f6d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6597,6 +6597,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Środowisko GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Zgłoszenia błędów trafiają do {host}.", + "glitchtipTargetOff": "Zgłoszenia błędów zostają na tym hoście. Nic nie jest wysyłane.", + "glitchtipTargetNoTarget": "Śledzenie błędów jest włączone, ale nie ustawiono celu, więc nic nie jest wysyłane.", + "glitchtipUnproven": "Niepotwierdzone", + "glitchtipDeliveryUnproven": "Cel jest ustawiony, ale żaden raport nie opuścił jeszcze tego hosta. Naciśnij Testuj, aby sprawdzić, czy zostanie przyjęty.", + "glitchtipDeliveryStale": "Przez ostatnie {hours} h żaden raport nie opuścił tego hosta. Naciśnij Testuj, aby sprawdzić, czy cel nadal je przyjmuje.", + "glitchtipDeliveryFailed": "Ostatni raport nie dotarł ({reason}).", "webPushVapidDescription": "Skonfiguruj tutaj klucze VAPID i subject, aby push przeglądarki działał bez zmiennych środowiskowych.", "webPushVapidPublicKey": "Klucz publiczny VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7012,7 +7019,23 @@ "restoreMissingDocuments": "Dokumenty", "restoreMissingWorkouts": "Treningi", "restoreMissingMentalHealth": "Kwestionariusze zdrowia psychicznego", - "restoreMissingConsent": "Potwierdzenia zgody" + "restoreMissingConsent": "Potwierdzenia zgody", + "offhost": { + "title": "Kopie poza hostem", + "description": "Kiedy ten host ostatnio umieścił zaszyfrowaną kopię każdego konta w Twoim buckecie.", + "loadError": "Nie udało się wczytać stanu poza hostem.", + "notConfiguredTitle": "Kopia poza hostem nie jest skonfigurowana", + "notConfiguredDescription": "Nie ustawiono ani bucketu, ani klucza, więc nic nie opuszcza tego hosta. Ustaw BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY i BACKUP_ENCRYPTION_KEY, aby włączyć nocne wysyłanie.", + "stateFresh": "Świeża", + "stateDue": "Zaległa", + "stateStale": "Przeterminowana", + "stateNever": "Nigdy", + "stateUnknown": "Brak wpisu", + "neverDetail": "Dla tego konta nic nie dotarło. Ostatnie sprawdzenie {when}.", + "unknownDetail": "Żaden nocny przebieg nie zarejestrował jeszcze tego konta.", + "age": "{hours} h temu", + "scheduleNote": "Wysyłka działa co {hours} h. Świeża znaczy w obrębie jednego przebiegu, zaległa że jeden nic nie dał, przeterminowana że dwa." + } }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/prisma/migrations/0336_offhost_backup_ledger/migration.sql b/prisma/migrations/0336_offhost_backup_ledger/migration.sql new file mode 100644 index 000000000..e0a66eaa8 --- /dev/null +++ b/prisma/migrations/0336_offhost_backup_ledger/migration.sql @@ -0,0 +1,24 @@ +-- One row per account, written by the nightly off-host backup job when an +-- object lands in the bucket. The admin console reads it to say, per account, +-- whether the newest off-host copy is as fresh as the schedule promises, +-- without the page ever talking to the bucket. +CREATE TABLE "offhost_backup_state" ( + "user_id" TEXT NOT NULL, + -- Written on every account the run walks, success or not. A row's mere + -- existence is what lets the console tell "no run has reached this + -- account yet" from "a run reached it and nothing landed". + "last_attempt_at" TIMESTAMP(3) NOT NULL, + -- NULL until an object lands for this account. + "last_success_at" TIMESTAMP(3), + -- BIGINT, not INTEGER: a single object may occupy 8 MiB x 10 000 multipart + -- parts, so the uploader permits 80 GB and a 32-bit column would refuse + -- every account past 2.1 GB. + "size_bytes" BIGINT, + + CONSTRAINT "offhost_backup_state_pkey" PRIMARY KEY ("user_id") +); + +ALTER TABLE "offhost_backup_state" + ADD CONSTRAINT "offhost_backup_state_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql b/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql new file mode 100644 index 000000000..75e271b60 --- /dev/null +++ b/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql @@ -0,0 +1,8 @@ +-- Delivery outcome of the last error report this host tried to send. +-- "A DSN is set and it parses" and "reports are arriving" are different +-- facts, and only the second one is worth a green badge: a wrong public key, +-- a wrong project id and an unreachable host all parse. +ALTER TABLE "app_settings" + ADD COLUMN "glitchtip_last_ok_at" TIMESTAMP(3), + ADD COLUMN "glitchtip_last_failure_at" TIMESTAMP(3), + ADD COLUMN "glitchtip_last_failure_reason" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c6e0a00a..ea050d2f7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -770,6 +770,7 @@ model User { notificationChannels NotificationChannel[] pushSubscriptions PushSubscription[] dataBackups DataBackup[] + offhostBackupState OffhostBackupState? achievements UserAchievement[] recommendationFeedback RecommendationFeedback[] idempotencyKeys IdempotencyKey[] @@ -4700,6 +4701,21 @@ model AppSettings { glitchtipDsn String? @map("glitchtip_dsn") glitchtipEnvironment String? @map("glitchtip_environment") + // Delivery outcome of the last error report this host tried to send. + // Without it the admin card can only say a DSN is set and parses, which is + // not the same fact as reports arriving: a wrong public key, a wrong project + // id or an unreachable host all parse perfectly and deliver nothing. + /// Instant the last report left successfully. Consumer: the GlitchTip card's + /// badge, which is only green while this is inside the recent window. + glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at") + /// Instant the last attempt failed. Consumer: the GlitchTip card's status + /// line, which names the failure rather than showing a reassuring badge. + glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at") + /// Short classification of that failure (`invalid_dsn`, `http_403`, + /// `network`) — never a response body, which is a remote host's text. + /// Consumer: the same status line. + glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason") + // Medication reminder thresholds (minutes after windowEnd) reminderLateMinutes Int @default(120) @map("reminder_late_minutes") reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes") @@ -4937,6 +4953,49 @@ model DataBackup { @@map("data_backups") } +// ─── Off-host backup ledger ────────────────────────────────── +// +// One row per account, written by the nightly off-host job the moment an +// object lands in the bucket. It exists because "the job ran" and "this +// account has a recent copy off-host" are different facts: the run reports +// counts, and a cohort of a hundred accounts where one has silently failed +// every night for a month reads exactly like a healthy one. +// +// The admin console's off-host card is the reader, and it is the only reader +// there will be: the page computes staleness from this timestamp rather than +// listing the bucket, so an operator who has revoked the worker's read grant +// still sees the truth. +model OffhostBackupState { + userId String @id @map("user_id") + + /// When the last nightly run walked this account, whether or not an object + /// came of it. It is what separates "no run has reached this account yet" + /// from "a run reached it and put nothing there" — two facts that look + /// identical in a ledger keyed only on success, and the first of which is + /// what every account reads on the day this table ships. Consumer: + /// `classifyOffhostBackup()` behind `GET /api/admin/backups`, and the + /// card's "last checked" line. + lastAttemptAt DateTime @map("last_attempt_at") + + /// When the newest object for this account finished uploading. NULL means + /// a run has looked at this account and nothing has ever landed for it. + /// Consumer: `classifyOffhostBackup()` behind `GET /api/admin/backups`. + lastSuccessAt DateTime? @map("last_success_at") + + /// Size of that object in bytes — the same number the run reports as its + /// largest, per account. `BigInt` because the uploader permits an object of + /// up to 8 MiB × 10 000 parts = 80 GB and a 32-bit column stops at 2.1 GB: + /// the biggest account on the host, the one an operator cares most about, + /// is exactly the one whose row would refuse to be written. + /// NULL alongside a NULL `lastSuccessAt`. + /// Consumer: the off-host card's size column. + sizeBytes BigInt? @map("size_bytes") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("offhost_backup_state") +} + // ─── Apple Health export.zip import (v1.4.34) ─────────────── // // Mirrors a `pg-boss` `apple-health-import` job's lifecycle so the diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index b991b8cab..6df0bd37e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -109,6 +109,10 @@ const PINNED_AFFORDANCES: Record< "src/components/admin/module-availability-section.tsx": { "toast.success": 1, }, + // Reads a ledger, writes nothing: the wash marks how fresh one account's + // off-host copy is, and the state it paints green came out of the database + // rather than out of a request this card just made. + "src/components/admin/offhost-backups-section.tsx": { "text-success": 1 }, "src/components/admin/recent-audit-preview.tsx": { CheckCircle2: 2, "text-success": 1, diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index f9548687e..24ac6042a 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -25,6 +25,7 @@ import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section" import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section"; import { BackupsSection } from "@/components/admin/backups-section"; +import { OffhostBackupsSection } from "@/components/admin/offhost-backups-section"; import { DangerZoneSection } from "@/components/admin/danger-zone-section"; import { EncryptionSection } from "@/components/admin/encryption-section"; import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; @@ -144,9 +145,12 @@ export function AdminSectionRenderer({ ); case "backups": + // Two cards, two independent failure modes: the copies held in this + // database, then the copies that reached the operator's bucket. return ( + ); case "encryption": diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts new file mode 100644 index 000000000..70b583451 --- /dev/null +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -0,0 +1,199 @@ +/** + * `GET /api/admin/backups` — the off-host leg. + * + * The rows the weekly pass writes into this database were already covered by + * the page that lists them. What was not covered is the question this response + * now answers: per account, when did an encrypted copy last reach the + * operator's bucket, and is that inside what the nightly schedule promises. + * + * The bucket is never touched here, and that is the assertion that matters — + * the verdicts come from the ledger the worker writes, so an operator whose + * worker holds no listing grant on the bucket still gets a true answer. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/jobs/job-failures", () => ({ + readLastQueueRun: vi.fn(async () => null), +})); + +const queryRawMock = vi.fn(); +const findManyMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: (...a: unknown[]) => queryRawMock(...a), + user: { findMany: (...a: unknown[]) => findManyMock(...a) }, + }, +})); + +const configuredMock = vi.fn(() => true); +vi.mock("@/lib/jobs/offhost-backup", () => ({ + offhostBackupConfigured: () => configuredMock(), +})); + +import { GET } from "../route"; +import type { BackupsList } from "@/types/backups"; + +const HOUR = 3_600_000; + +async function read(): Promise { + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(200); + return (await res.json()).data as BackupsList; +} + +beforeEach(() => { + vi.clearAllMocks(); + configuredMock.mockReturnValue(true); + queryRawMock.mockResolvedValue([]); + findManyMock.mockResolvedValue([]); +}); + +describe("GET /api/admin/backups — off-host freshness", () => { + it("rates every account against the nightly schedule without listing the bucket", async () => { + const now = Date.now(); + findManyMock.mockResolvedValue([ + { + id: "u1", + username: "account-one", + offhostBackupState: { + lastAttemptAt: new Date(now - 3 * HOUR), + lastSuccessAt: new Date(now - 3 * HOUR), + // The column is BigInt — an object may be 80 GB — and the wire type + // is a plain number. + sizeBytes: BigInt(4096), + }, + }, + { + id: "u2", + username: "account-two", + offhostBackupState: { + lastAttemptAt: new Date(now - 31 * HOUR), + lastSuccessAt: new Date(now - 31 * HOUR), + sizeBytes: BigInt(8192), + }, + }, + { + id: "u3", + username: "account-three", + offhostBackupState: { + lastAttemptAt: new Date(now - 100 * HOUR), + lastSuccessAt: new Date(now - 100 * HOUR), + sizeBytes: BigInt(128), + }, + }, + { + id: "u4", + username: "account-four", + // Walked by a run, nothing landed. + offhostBackupState: { + lastAttemptAt: new Date(now - 2 * HOUR), + lastSuccessAt: null, + sizeBytes: null, + }, + }, + // No run has recorded this one at all — the state of every account on + // the morning the ledger ships. + { id: "u5", username: "account-five", offhostBackupState: null }, + ]); + + const body = await read(); + + expect(body.offhost.configured).toBe(true); + expect(body.offhost.periodHours).toBe(24); + // Worst first, alphabetical within a verdict. The database hands them + // back by username; the one account that needs attention must not be + // wherever the alphabet leaves it on a hundred-account cohort. + expect( + body.offhost.rows.map((row) => [row.username, row.freshness]), + ).toEqual([ + ["account-three", "stale"], + ["account-two", "due"], + ["account-four", "never"], + ["account-five", "unknown"], + ["account-one", "fresh"], + ]); + const byName = new Map(body.offhost.rows.map((row) => [row.username, row])); + expect(byName.get("account-one")?.sizeBytes).toBe(4096); + expect(typeof byName.get("account-one")?.sizeBytes).toBe("number"); + expect(byName.get("account-one")?.ageHours).toBe(3); + expect(byName.get("account-four")?.lastSuccessAt).toBeNull(); + expect(byName.get("account-four")?.sizeBytes).toBeNull(); + expect(byName.get("account-four")?.lastAttemptAt).not.toBeNull(); + // `unknown` is the honest one: the ledger has no history for this + // account, which is not the same claim as "the bucket is empty". + expect(byName.get("account-five")?.lastAttemptAt).toBeNull(); + }); + + it("says so when neither the environment nor the ledger knows of any upload", async () => { + configuredMock.mockReturnValue(false); + findManyMock.mockResolvedValue([ + { id: "u1", username: "account-one", offhostBackupState: null }, + ]); + + const body = await read(); + + expect(body.offhost).toEqual({ + configured: false, + periodHours: 24, + rows: [], + }); + }); + + it("lets a ledger row outrank this process's own environment", async () => { + // A hand-rolled split deployment can give BACKUP_S3_* to the worker and + // not to the web process. The rows are proof uploads are happening; + // saying "nothing leaves this host" over them would be this process + // reporting on a job it cannot see. + configuredMock.mockReturnValue(false); + findManyMock.mockResolvedValue([ + { + id: "u1", + username: "account-one", + offhostBackupState: { + lastAttemptAt: new Date(), + lastSuccessAt: new Date(), + sizeBytes: BigInt(4096), + }, + }, + ]); + + const body = await read(); + + expect(body.offhost.configured).toBe(true); + expect(body.offhost.rows.map((row) => row.freshness)).toEqual(["fresh"]); + }); + + it("never ships a credential or an object key with the verdicts", async () => { + findManyMock.mockResolvedValue([ + { + id: "u1", + username: "account-one", + offhostBackupState: { + lastAttemptAt: new Date(), + lastSuccessAt: new Date(), + sizeBytes: BigInt(1), + }, + }, + ]); + + const body = await read(); + + expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([ + "ageHours", + "freshness", + "lastAttemptAt", + "lastSuccessAt", + "sizeBytes", + "userId", + "username", + ]); + expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i); + }); +}); diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 04a8c2139..3aab2ff45 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -13,6 +13,12 @@ * Without those two the page cannot tell a working backup from one that * stopped six weeks ago — every row it lists has a perfectly ordinary * timestamp either way. + * + * 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 + * worker writes, never from a bucket listing — the answer has to hold on a host + * whose worker holds no listing grant on the bucket. */ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; @@ -21,10 +27,20 @@ import { annotate } from "@/lib/logging/context"; import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy"; import { readLastQueueRun } from "@/lib/jobs/job-failures"; import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status"; +import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup"; +import { + classifyOffhostBackup, + OFFHOST_BACKUP_PERIOD_HOURS, + type OffhostBackupFreshness, +} from "@/lib/jobs/offhost-backup-freshness"; // v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts` // so callers (in particular `components/admin/backups-section.tsx`) don't // have to reach across the component → route-handler layer boundary. -import type { BackupRow, BackupsList } from "@/types/backups"; +import type { + BackupRow, + BackupsList, + OffhostAccountRow, +} from "@/types/backups"; export const dynamic = "force-dynamic"; @@ -67,6 +83,79 @@ export const GET = apiHandler(async () => { createdAt: row.created_at.toISOString(), })); + // The off-host leg, per account, read from the ledger the nightly worker + // writes rather than from the bucket. The page must be able to answer "which + // account has no recent copy off-host" on a host whose worker holds the + // three grants the runbook documents and no listing grant at all, and a + // listing call from a page render would put the operator's credentials on + // the request path of every page view. + const now = new Date(); + const offhostAccounts = await prisma.user.findMany({ + select: { + id: true, + username: true, + offhostBackupState: { + select: { + lastAttemptAt: true, + lastSuccessAt: true, + sizeBytes: true, + }, + }, + }, + orderBy: { username: "asc" }, + }); + + // Evidence beats this process's environment. `offhostBackupConfigured()` + // reads the variables of whichever process serves this request, and a + // hand-rolled split that gives `BACKUP_S3_*` only to the worker would make + // the web process say "nothing leaves this host" over a ledger full of + // fresh rows. A row in that table is proof an upload happened; no + // environment read can outrank it. + const configured = + offhostBackupConfigured() || + offhostAccounts.some((account) => account.offhostBackupState !== null); + + const offhostRows: OffhostAccountRow[] = offhostAccounts.map((account) => { + const state = account.offhostBackupState; + const verdict = classifyOffhostBackup({ + lastAttemptAt: state?.lastAttemptAt ?? null, + lastSuccessAt: state?.lastSuccessAt ?? null, + now, + }); + return { + userId: account.id, + username: account.username, + lastAttemptAt: state?.lastAttemptAt.toISOString() ?? null, + lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null, + // BigInt column -> number on the wire. The value is bytes of one + // object, capped by the uploader at 80 GB, so it is nowhere near + // Number.MAX_SAFE_INTEGER and JSON has no BigInt. + sizeBytes: + state?.sizeBytes === null || state?.sizeBytes === undefined + ? null + : Number(state.sizeBytes), + ageHours: verdict.ageHours, + freshness: verdict.freshness, + }; + }); + + // Worst first. Sorting by username puts the one account that needs + // attention wherever the alphabet happens to leave it, which on a + // hundred-account cohort is the same "one bad row hides in the crowd" + // problem this card was built to end, moved out of a count and into a list. + const FRESHNESS_ORDER: Record = { + stale: 0, + due: 1, + never: 2, + unknown: 3, + fresh: 4, + }; + offhostRows.sort( + (a, b) => + FRESHNESS_ORDER[a.freshness] - FRESHNESS_ORDER[b.freshness] || + a.username.localeCompare(b.username), + ); + const payload: BackupsList = { rows: list, // Matches the retention window the backup-prune job enforces so the @@ -77,8 +166,15 @@ export const GET = apiHandler(async () => { .filter((row) => row.type === SCHEDULED_BACKUP_TYPE) .map((row) => row.created_at), lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE), - now: new Date(), + now, }), + offhost: { + configured, + periodHours: OFFHOST_BACKUP_PERIOD_HOURS, + // An unconfigured host has nothing to list, and the card says so rather + // than painting an empty table that reads as "no problems". + rows: configured ? offhostRows : [], + }, }; return apiSuccess(payload); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index bfbaf9b89..3c32e5953 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -16,9 +16,32 @@ import { invalidateServerDefaultTimezone, isValidTimezone, } from "@/lib/tz/resolver"; +import { summariseGlitchtipDelivery } from "@/lib/monitoring/glitchtip-delivery"; export const dynamic = "force-dynamic"; +/** The delivery verdict, flattened onto the settings payload. */ +function summariseGlitchtipDeliveryFields(state: { + lastOkAt?: Date | null; + lastFailureAt?: Date | null; + lastFailureReason?: string | null; +}) { + const summary = summariseGlitchtipDelivery( + { + lastOkAt: state.lastOkAt ?? null, + lastFailureAt: state.lastFailureAt ?? null, + lastFailureReason: state.lastFailureReason ?? null, + }, + new Date(), + ); + return { + glitchtipReportsDelivering: summary.reportsDelivering, + glitchtipEverDelivered: summary.everDelivered, + glitchtipLastFailureReason: summary.lastFailureReason, + glitchtipDeliveryWindowHours: summary.windowHours, + }; +} + export const GET = apiHandler(async () => { await requireAdmin(); annotate({ action: { name: "admin.settings.get" } }); @@ -48,6 +71,15 @@ export const GET = apiHandler(async () => { glitchtipEnabled: settings?.glitchtipEnabled ?? false, glitchtipDsn: settings?.glitchtipDsn ?? null, glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production", + // The delivery outcome, not the intent, and resolved here rather than on + // the client: a DSN that is set and parses says a target was typed, only + // this says something left the host, and whether it left recently enough + // is a question about a clock that belongs with the ledger. + ...summariseGlitchtipDeliveryFields({ + lastOkAt: settings?.glitchtipLastOkAt ?? null, + lastFailureAt: settings?.glitchtipLastFailureAt ?? null, + lastFailureReason: settings?.glitchtipLastFailureReason ?? null, + }), reminderLateMinutes: settings?.reminderLateMinutes ?? 120, reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240, // Document vault limits (per-file cap + per-user quota default). BigInt @@ -274,6 +306,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { glitchtipEnabled: settings.glitchtipEnabled, glitchtipDsn: settings.glitchtipDsn, glitchtipEnvironment: settings.glitchtipEnvironment ?? "production", + ...summariseGlitchtipDeliveryFields({ + lastOkAt: settings.glitchtipLastOkAt, + lastFailureAt: settings.glitchtipLastFailureAt, + lastFailureReason: settings.glitchtipLastFailureReason, + }), reminderLateMinutes: settings.reminderLateMinutes, reminderMissedMinutes: settings.reminderMissedMinutes, documentMaxFileBytes: settings.documentMaxFileBytes, diff --git a/src/components/admin/__tests__/glitchtip-delivery-badge.test.tsx b/src/components/admin/__tests__/glitchtip-delivery-badge.test.tsx new file mode 100644 index 000000000..1a4e70b1d --- /dev/null +++ b/src/components/admin/__tests__/glitchtip-delivery-badge.test.tsx @@ -0,0 +1,97 @@ +/** + * The GlitchTip badge answers "are reports arriving", not "did somebody type + * a DSN". + * + * The two questions used to have one answer: enabled plus a URL that parses. + * A wrong public key, a wrong project id and a host that stopped answering all + * parse perfectly, so a green badge over any of them is the false reassurance + * an operator only discovers when they go looking for a crash that was never + * reported. The card now reads the delivery outcome this host recorded. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { AdminSettings } from "../_shared"; + +const settingsResult = vi.hoisted(() => ({ + current: null as Partial | null, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: settingsResult.current }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +vi.mock("../_shared", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAdminSettings: () => ({ data: settingsResult.current }), + useUpdateSettings: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { GlitchtipSection } from "../glitchtip-section"; + +const BASE = { + glitchtipEnabled: true, + glitchtipDsn: "https://public-key@glitchtip.example.com/7", + glitchtipEnvironment: "production", + glitchtipReportsDelivering: false, + glitchtipEverDelivered: false, + glitchtipLastFailureReason: null, + glitchtipDeliveryWindowHours: 336, +} satisfies Partial; + +function render(settings: Partial | null): string { + settingsResult.current = settings; + return renderToStaticMarkup( + + + , + ); +} + +describe(" delivery badge", () => { + it("does not call a parseable DSN configured until something has left the host", () => { + const html = render(BASE); + expect(html).toContain("Unproven"); + expect(html).not.toContain(">Configured<"); + expect(html).toContain("no report has left this host yet"); + }); + + it("names the failure when the target refused the last report", () => { + const html = render({ ...BASE, glitchtipLastFailureReason: "http_403" }); + expect(html).toContain("Unproven"); + expect(html).toContain("did not get through"); + expect(html).toContain("http_403"); + }); + + it("goes green only for a report that left inside the window", () => { + const html = render({ + ...BASE, + glitchtipReportsDelivering: true, + glitchtipEverDelivered: true, + }); + expect(html).toContain(">Configured<"); + expect(html).toContain("glitchtip.example.com"); + expect(html).not.toContain('data-slot="glitchtip-delivery"'); + }); + + it("stops claiming delivery once the last success ages out of the window", () => { + const html = render({ + ...BASE, + glitchtipDeliveryWindowHours: 24, + glitchtipEverDelivered: true, + }); + expect(html).toContain("Unproven"); + expect(html).toContain("No report has left this host in the last"); + }); + + it("says nothing is sent when the switch is off", () => { + const html = render({ ...BASE, glitchtipEnabled: false }); + expect(html).toContain("Error reports stay on this host"); + expect(html).not.toContain("Unproven"); + }); +}); diff --git a/src/components/admin/__tests__/offhost-backups-section.test.tsx b/src/components/admin/__tests__/offhost-backups-section.test.tsx new file mode 100644 index 000000000..d3dd46985 --- /dev/null +++ b/src/components/admin/__tests__/offhost-backups-section.test.tsx @@ -0,0 +1,112 @@ +/** + * The off-host card paints what the response says, including the case an + * operator most needs stated out loud: this host is not sending anything + * anywhere. A card that renders an empty list on an unconfigured host reads + * as "no problems". + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { BackupsList } from "@/types/backups"; + +const queryResult = vi.hoisted(() => ({ + current: { + data: null as BackupsList | null, + isLoading: false, + isError: false, + }, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ ...queryResult.current, refetch: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { OffhostBackupsSection } from "../offhost-backups-section"; + +function render(data: BackupsList["offhost"] | null): string { + queryResult.current = { + data: + data === null + ? null + : ({ + rows: [], + retentionDays: 90, + schedule: null, + offhost: data, + } as unknown as BackupsList), + isLoading: false, + isError: false, + }; + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("says off-host backup is not configured rather than showing an empty list", () => { + const html = render({ configured: false, periodHours: 24, rows: [] }); + expect(html).toContain("Off-host backup is not configured"); + expect(html).not.toContain('data-slot="offhost-backup-rows"'); + }); + + it("paints one row per account with its verdict", () => { + const html = render({ + configured: true, + periodHours: 24, + rows: [ + { + userId: "u1", + username: "account-one", + lastAttemptAt: "2026-09-10T06:00:00.000Z", + lastSuccessAt: "2026-09-10T06:00:00.000Z", + sizeBytes: 4096, + ageHours: 3, + freshness: "fresh", + }, + { + userId: "u2", + username: "account-two", + lastAttemptAt: "2026-09-06T06:00:00.000Z", + lastSuccessAt: "2026-09-06T06:00:00.000Z", + sizeBytes: 2048, + ageHours: 99, + freshness: "stale", + }, + { + userId: "u3", + username: "account-three", + lastAttemptAt: "2026-09-10T02:30:00.000Z", + lastSuccessAt: null, + sizeBytes: null, + ageHours: null, + freshness: "never", + }, + { + userId: "u4", + username: "account-four", + lastAttemptAt: null, + lastSuccessAt: null, + sizeBytes: null, + ageHours: null, + freshness: "unknown", + }, + ], + }); + + expect(html).toContain('data-offhost-freshness="fresh"'); + expect(html).toContain('data-offhost-freshness="stale"'); + expect(html).toContain('data-offhost-freshness="never"'); + expect(html).toContain('data-offhost-freshness="unknown"'); + expect(html).toContain("account-two"); + // The account a run walked and found nothing for says that, with the + // instant it was checked. + expect(html).toContain("Nothing has landed for this account"); + // The account no run has recorded says THAT — not "never", which would + // claim the bucket is empty on the strength of an empty ledger. + expect(html).toContain("No nightly run has recorded this account yet"); + // Never the bucket's own coordinates. + expect(html).not.toMatch(/secret|access[- ]key/i); + }); +}); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 906262788..40b41064f 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -101,6 +101,17 @@ export interface AdminSettings { glitchtipEnabled: boolean; glitchtipDsn: string | null; glitchtipEnvironment: string | null; + /** A report left this host successfully inside the delivery window. */ + glitchtipReportsDelivering: boolean; + /** A report has left this host successfully at some point. */ + glitchtipEverDelivered: boolean; + /** + * Why the last attempt failed, when that failure is newer than the last + * success. A short classification, never a remote response body. + */ + glitchtipLastFailureReason: string | null; + /** How long a successful send stands for before the badge stops claiming it. */ + glitchtipDeliveryWindowHours: number; reminderLateMinutes: number; reminderMissedMinutes: number; // v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver". diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index f6f448a0a..fb065f101 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -47,6 +47,7 @@ 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"; @@ -322,14 +323,6 @@ function RestoreRowDialog({ ); } -function formatBytes(bytes: number, fmt: ReturnType) { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) { - return `${fmt.integer(Math.round(bytes / 1024))} KB`; - } - return `${fmt.number(bytes / 1024 / 1024, 2)} MB`; -} - /** * Render the `DataBackup.type` enum (`WEEKLY_AUTO` / `MANUAL`) as a * human label. Unknown values fall through to the raw enum so a future diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index 746c8059e..611860584 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -1,12 +1,13 @@ "use client"; import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -18,9 +19,11 @@ import { ConfiguredBadge, } from "./_shared"; import { apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; export function GlitchtipSection() { const { t } = useTranslations(); + const queryClient = useQueryClient(); const { data: settings } = useAdminSettings(); const updateSettings = useUpdateSettings(); const [glitchtipDsnDraft, setGlitchtipDsnDraft] = useState( @@ -34,7 +37,63 @@ export function GlitchtipSection() { const glitchtipEnvironmentValue = glitchtipEnvironmentDraft ?? settings?.glitchtipEnvironment ?? "production"; - const configured = Boolean(settings?.glitchtipDsn); + /** + * The host the DSN addresses, and nothing else from it. An operator asking + * "where do my errors go" is asking for a hostname; the public key in the + * DSN answers a different question and does not belong in a status line. + * + * There is deliberately no default here and none anywhere else in the tree: + * a self-hoster's crash reports must not leave their host because nobody + * told them not to. Reporting starts when this operator types a target. + */ + const targetHost = ((): string | null => { + const dsn = settings?.glitchtipDsn; + if (!dsn) return null; + try { + return new URL(dsn).host || null; + } catch { + return null; + } + })(); + + /** + * Three states, because "a target was typed" and "reports are arriving" are + * different facts and only the second earns a green badge. + * + * off — the switch is off, or no target parses. Nothing is sent. + * unproven — a target is set and nothing has come back from it inside the + * window. A wrong public key, a wrong project id and a host + * that stopped answering all look exactly like this, which is + * why it is not green. + * delivering — a report left successfully inside the window. The test + * button is the cheap way to prove it on demand. + */ + const deliveryState = ((): "off" | "unproven" | "delivering" => { + if (!settings?.glitchtipEnabled || !targetHost) return "off"; + // Whether the last success is recent enough is decided where the ledger + // lives, not here: a render is not the place to read a clock. + return settings.glitchtipReportsDelivering ? "delivering" : "unproven"; + })(); + + /** + * Why the badge is not green, in words. A reason the host recorded beats a + * generic "unproven": an operator who typed a DSN with the wrong project id + * gets told the target refused it rather than left to guess. + */ + const unprovenReason = ((): string | null => { + if (deliveryState !== "unproven") return null; + if (settings?.glitchtipLastFailureReason) { + return t("admin.glitchtipDeliveryFailed", { + reason: settings.glitchtipLastFailureReason, + }); + } + if (settings?.glitchtipEverDelivered) { + return t("admin.glitchtipDeliveryStale", { + hours: settings.glitchtipDeliveryWindowHours, + }); + } + return t("admin.glitchtipDeliveryUnproven"); + })(); const testGlitchtip = useMutation({ mutationFn: async () => { @@ -45,6 +104,11 @@ export function GlitchtipSection() { }, onSuccess: (message) => { toast.success(message); + // The send just recorded an outcome. Re-read it, so the badge answers + // the question the operator pressed the button to ask. + void queryClient.invalidateQueries({ + queryKey: queryKeys.adminSettings(), + }); }, onError: (error) => { toast.error( @@ -52,6 +116,9 @@ export function GlitchtipSection() { ? error.message : t("admin.monitoringTestFailed"), ); + void queryClient.invalidateQueries({ + queryKey: queryKeys.adminSettings(), + }); }, }); @@ -76,7 +143,15 @@ export function GlitchtipSection() { icon={AlertTriangle} title={t("admin.glitchtipTitle")} description={t("admin.glitchtipDescription")} - status={configured ? : null} + status={ + deliveryState === "delivering" ? ( + + ) : deliveryState === "unproven" ? ( + + {t("admin.glitchtipUnproven")} + + ) : null + } />
@@ -89,6 +164,28 @@ export function GlitchtipSection() { } disabled={updateSettings.isPending} /> + {/* Only once the settings are in hand: a line that says "nothing is + sent" while the answer is still loading is a claim, not a + placeholder. */} + {settings ? ( +
+

+ {deliveryState !== "off" && targetHost + ? t("admin.glitchtipTargetOn", { host: targetHost }) + : settings.glitchtipEnabled + ? t("admin.glitchtipTargetNoTarget") + : t("admin.glitchtipTargetOff")} +

+ {unprovenReason ? ( +

+ {unprovenReason} +

+ ) : null} +
+ ) : null}