diff --git a/messages/de.json b/messages/de.json index 96a78eff5..16d99bc5d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8348,7 +8348,10 @@ "retentionOffhost": "Externe Kopien entfernt die Lebenszyklus-Regel des Buckets nach {days} Tagen.", "retentionOffhostMissing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.", "notPersistentOffhost": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher). Ein Dump dort wäre beim nächsten Update verloren. Dumps gehen deshalb nur extern: Hier wird keiner aufbewahrt, und vor einer Migration wird keiner erstellt. Binde das Backup-Volume ein.", - "notPersistent": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher), und es ist kein externer Bucket eingerichtet. Damit wird nirgends ein Datenbank-Dump aufbewahrt. Binde das Backup-Volume ein." + "notPersistent": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher), und es ist kein externer Bucket eingerichtet. Damit wird nirgends ein Datenbank-Dump aufbewahrt. Binde das Backup-Volume ein.", + "offhostFailedTitle": "Der neueste Datenbank-Dump ist nicht im externen Bucket angekommen", + "offhostLastUpload": "Der neueste Dump im Bucket stammt vom {when}.", + "offhostNeverUploaded": "Bisher ist kein Datenbank-Dump im Bucket angekommen." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "Der neueste Datenbank-Dump ist aktuell.", "nowhere_to_write": "Das Dump-Verzeichnis {directory} ist nicht beschreibbar und es ist kein externer Bucket eingerichtet, daher kann kein Dump aufbewahrt werden.", "not_persistent": "Das Dump-Verzeichnis {directory} liegt nicht auf dauerhaftem Speicher, und es ist kein externer Bucket eingerichtet. Jeder Dump wäre beim nächsten Update verloren. Binde das Backup-Volume ein.", - "fresh_offhost_only": "Der neueste Datenbank-Dump ist aktuell und liegt im externen Bucket. Auf diesem Server wird keiner aufbewahrt, weil {directory} nicht auf dauerhaftem Speicher liegt, und vor einer Migration wird keiner erstellt." + "fresh_offhost_only": "Der neueste Datenbank-Dump ist aktuell und liegt im externen Bucket. Auf diesem Server wird keiner aufbewahrt, weil {directory} nicht auf dauerhaftem Speicher liegt, und vor einer Migration wird keiner erstellt.", + "offhost_failed": "Der neueste Datenbank-Dump liegt auf diesem Server, aber das Hochladen in den externen Bucket ist fehlgeschlagen.", + "offhost_stale": "Seit {hours} Stunden ist kein Datenbank-Dump im externen Bucket angekommen, das Hochladen schlägt immer wieder fehl. Geht dieser Server verloren, gibt es keinen aktuellen Dump, um ihn wiederherzustellen.", + "offhost_never": "Noch nie ist ein Datenbank-Dump im externen Bucket angekommen, das Hochladen schlägt fehl. Geht dieser Server verloren, gibt es keinen Dump, um ihn wiederherzustellen." }, "weekly_copy": { "no_accounts": "Es gibt noch keine Konten zum Kopieren.", diff --git a/messages/en.json b/messages/en.json index bb4cf34c4..b96cfa923 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8348,7 +8348,10 @@ "retentionOffhost": "Off-host copies are removed after {days} days by the bucket's lifecycle rule.", "retentionOffhostMissing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix.", "notPersistentOffhost": "{dir} is not on persistent storage (it sits inside the container rather than on a mounted volume, or in memory), so a dump written there would be lost at the next update. Dumps go off-host only: none is kept here and none is taken before a migration. Mount the backups volume.", - "notPersistent": "{dir} is not on persistent storage (it sits inside the container rather than on a mounted volume, or in memory) and no off-host bucket is set, so no database dump is kept anywhere. Mount the backups volume." + "notPersistent": "{dir} is not on persistent storage (it sits inside the container rather than on a mounted volume, or in memory) and no off-host bucket is set, so no database dump is kept anywhere. Mount the backups volume.", + "offhostFailedTitle": "The newest database dump did not reach the off-host bucket", + "offhostLastUpload": "The newest dump in the bucket is from {when}.", + "offhostNeverUploaded": "No database dump has reached the bucket yet." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "The newest database dump is recent.", "nowhere_to_write": "The dump directory {directory} cannot be written to and no off-host bucket is set up, so no dump can be kept.", "not_persistent": "The dump directory {directory} is not on persistent storage and no off-host bucket is set up, so every dump would be lost at the next update. Mount the backups volume.", - "fresh_offhost_only": "The newest database dump is recent and in the off-host bucket. None is kept on this server, because {directory} is not on persistent storage, and none is taken before a migration." + "fresh_offhost_only": "The newest database dump is recent and in the off-host bucket. None is kept on this server, because {directory} is not on persistent storage, and none is taken before a migration.", + "offhost_failed": "The newest database dump is kept on this server, but its upload to the off-host bucket failed.", + "offhost_stale": "No database dump has reached the off-host bucket for {hours} hours: the uploads keep failing. If this server is lost, there is no recent dump to rebuild it from.", + "offhost_never": "No database dump has ever reached the off-host bucket: the upload fails. If this server is lost, there is no dump to rebuild it from." }, "weekly_copy": { "no_accounts": "There are no accounts to copy yet.", diff --git a/messages/es.json b/messages/es.json index 5c9056ae2..3036344c7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8348,7 +8348,10 @@ "retentionOffhost": "La regla de ciclo de vida del bucket elimina las copias externas tras {days} días.", "retentionOffhostMissing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo.", "notPersistentOffhost": "{dir} no está en almacenamiento persistente (está dentro del contenedor y no en un volumen montado, o en memoria), así que un volcado guardado ahí se perdería en la próxima actualización. Los volcados solo se guardan fuera: aquí no queda ninguno y no se hace ninguno antes de una migración. Monta el volumen de copias.", - "notPersistent": "{dir} no está en almacenamiento persistente (está dentro del contenedor y no en un volumen montado, o en memoria) y no hay un bucket externo configurado, así que no se conserva ningún volcado de la base de datos en ningún sitio. Monta el volumen de copias." + "notPersistent": "{dir} no está en almacenamiento persistente (está dentro del contenedor y no en un volumen montado, o en memoria) y no hay un bucket externo configurado, así que no se conserva ningún volcado de la base de datos en ningún sitio. Monta el volumen de copias.", + "offhostFailedTitle": "El volcado más reciente de la base de datos no llegó al bucket externo", + "offhostLastUpload": "El volcado más reciente del bucket es del {when}.", + "offhostNeverUploaded": "Todavía no ha llegado ningún volcado de la base de datos al bucket." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "El último volcado de la base de datos es reciente.", "nowhere_to_write": "No se puede escribir en el directorio de volcados {directory} y no hay ningún bucket externo configurado, así que no se puede guardar ningún volcado.", "not_persistent": "El directorio de volcados {directory} no está en almacenamiento persistente y no hay un bucket externo configurado, así que cada volcado se perdería en la próxima actualización. Monta el volumen de copias.", - "fresh_offhost_only": "El volcado más reciente de la base de datos es actual y está en el bucket externo. En este servidor no se conserva ninguno, porque {directory} no está en almacenamiento persistente, y no se hace ninguno antes de una migración." + "fresh_offhost_only": "El volcado más reciente de la base de datos es actual y está en el bucket externo. En este servidor no se conserva ninguno, porque {directory} no está en almacenamiento persistente, y no se hace ninguno antes de una migración.", + "offhost_failed": "El volcado más reciente de la base de datos se guarda en este servidor, pero la subida al bucket externo falló.", + "offhost_stale": "Ningún volcado de la base de datos ha llegado al bucket externo en {hours} horas: las subidas siguen fallando. Si se pierde este servidor, no hay un volcado reciente para reconstruirlo.", + "offhost_never": "Ningún volcado de la base de datos ha llegado nunca al bucket externo: la subida falla. Si se pierde este servidor, no hay ningún volcado para reconstruirlo." }, "weekly_copy": { "no_accounts": "Todavía no hay cuentas que copiar.", diff --git a/messages/fr.json b/messages/fr.json index ddc754baf..59f8f5c0c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8348,7 +8348,10 @@ "retentionOffhost": "La règle de cycle de vie du bucket supprime les copies hors site après {days} jours.", "retentionOffhostMissing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe.", "notPersistentOffhost": "{dir} n'est pas sur un stockage persistant (il se trouve dans le conteneur et non sur un volume monté, ou en mémoire) : un dump écrit là serait perdu à la prochaine mise à jour. Les dumps partent uniquement hors site : aucun n'est conservé ici et aucun n'est pris avant une migration. Montez le volume de sauvegarde.", - "notPersistent": "{dir} n'est pas sur un stockage persistant (il se trouve dans le conteneur et non sur un volume monté, ou en mémoire) et aucun bucket hors site n'est configuré : aucun dump de la base n'est conservé nulle part. Montez le volume de sauvegarde." + "notPersistent": "{dir} n'est pas sur un stockage persistant (il se trouve dans le conteneur et non sur un volume monté, ou en mémoire) et aucun bucket hors site n'est configuré : aucun dump de la base n'est conservé nulle part. Montez le volume de sauvegarde.", + "offhostFailedTitle": "Le dump le plus récent de la base de données n'est pas arrivé dans le bucket externe", + "offhostLastUpload": "Le dump le plus récent du bucket date du {when}.", + "offhostNeverUploaded": "Aucun dump de la base de données n'est encore arrivé dans le bucket." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "Le dernier dump de la base de données est récent.", "nowhere_to_write": "Le dossier des dumps {directory} n'est pas accessible en écriture et aucun bucket externe n'est configuré, donc aucun dump ne peut être conservé.", "not_persistent": "Le répertoire des dumps {directory} n'est pas sur un stockage persistant et aucun bucket hors site n'est configuré : chaque dump serait perdu à la prochaine mise à jour. Montez le volume de sauvegarde.", - "fresh_offhost_only": "Le dump de base le plus récent est à jour et se trouve dans le bucket hors site. Aucun n'est conservé sur ce serveur, car {directory} n'est pas sur un stockage persistant, et aucun n'est pris avant une migration." + "fresh_offhost_only": "Le dump de base le plus récent est à jour et se trouve dans le bucket hors site. Aucun n'est conservé sur ce serveur, car {directory} n'est pas sur un stockage persistant, et aucun n'est pris avant une migration.", + "offhost_failed": "Le dump le plus récent de la base de données est conservé sur ce serveur, mais son envoi vers le bucket externe a échoué.", + "offhost_stale": "Aucun dump de la base de données n'est arrivé dans le bucket externe depuis {hours} heures : les envois échouent à chaque fois. Si ce serveur est perdu, aucun dump récent ne permet de le reconstruire.", + "offhost_never": "Aucun dump de la base de données n'est jamais arrivé dans le bucket externe : l'envoi échoue. Si ce serveur est perdu, aucun dump ne permet de le reconstruire." }, "weekly_copy": { "no_accounts": "Il n'y a pas encore de compte à copier.", diff --git a/messages/it.json b/messages/it.json index fe6450c8d..1b93f64e4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8348,7 +8348,10 @@ "retentionOffhost": "La regola del ciclo di vita del bucket elimina le copie esterne dopo {days} giorni.", "retentionOffhostMissing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso.", "notPersistentOffhost": "{dir} non è su uno spazio persistente (si trova dentro il container e non su un volume montato, oppure in memoria), quindi un dump scritto lì andrebbe perso al prossimo aggiornamento. I dump vanno solo fuori sede: qui non ne resta nessuno e prima di una migrazione non ne viene fatto nessuno. Monta il volume dei backup.", - "notPersistent": "{dir} non è su uno spazio persistente (si trova dentro il container e non su un volume montato, oppure in memoria) e non è configurato alcun bucket esterno, quindi nessun dump del database viene conservato da nessuna parte. Monta il volume dei backup." + "notPersistent": "{dir} non è su uno spazio persistente (si trova dentro il container e non su un volume montato, oppure in memoria) e non è configurato alcun bucket esterno, quindi nessun dump del database viene conservato da nessuna parte. Monta il volume dei backup.", + "offhostFailedTitle": "Il dump più recente del database non è arrivato nel bucket esterno", + "offhostLastUpload": "Il dump più recente nel bucket è del {when}.", + "offhostNeverUploaded": "Nessun dump del database è ancora arrivato nel bucket." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "L'ultimo dump del database è recente.", "nowhere_to_write": "La cartella dei dump {directory} non è scrivibile e non è configurato nessun bucket esterno, quindi non si può conservare alcun dump.", "not_persistent": "La cartella dei dump {directory} non è su uno spazio persistente e non è configurato alcun bucket esterno, quindi ogni dump andrebbe perso al prossimo aggiornamento. Monta il volume dei backup.", - "fresh_offhost_only": "Il dump del database più recente è aggiornato e si trova nel bucket esterno. Su questo server non ne viene conservato nessuno, perché {directory} non è su uno spazio persistente, e prima di una migrazione non ne viene fatto nessuno." + "fresh_offhost_only": "Il dump del database più recente è aggiornato e si trova nel bucket esterno. Su questo server non ne viene conservato nessuno, perché {directory} non è su uno spazio persistente, e prima di una migrazione non ne viene fatto nessuno.", + "offhost_failed": "Il dump più recente del database è conservato su questo server, ma il caricamento nel bucket esterno non è riuscito.", + "offhost_stale": "Da {hours} ore nessun dump del database è arrivato nel bucket esterno: i caricamenti continuano a non riuscire. Se questo server va perso, non c'è un dump recente da cui ricostruirlo.", + "offhost_never": "Nessun dump del database è mai arrivato nel bucket esterno: il caricamento non riesce. Se questo server va perso, non c'è alcun dump da cui ricostruirlo." }, "weekly_copy": { "no_accounts": "Non ci sono ancora account da copiare.", diff --git a/messages/ko.json b/messages/ko.json index 2ef2164d3..9f9e1a5da 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8323,7 +8323,10 @@ "retentionOffhost": "버킷의 수명 주기 규칙이 외부 사본을 {days}일 후에 삭제합니다.", "retentionOffhostMissing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요.", "notPersistentOffhost": "{dir}은(는) 영구 저장소에 있지 않습니다(마운트된 볼륨이 아니라 컨테이너 안이나 메모리에 있음). 그래서 여기에 쓴 덤프는 다음 업데이트 때 사라집니다. 덤프는 외부 저장소로만 가며, 여기에는 보관되지 않고 마이그레이션 전에도 만들어지지 않습니다. 백업 볼륨을 마운트하세요.", - "notPersistent": "{dir}은(는) 영구 저장소에 있지 않고(마운트된 볼륨이 아니라 컨테이너 안이나 메모리에 있음) 외부 버킷도 설정되지 않아, 데이터베이스 덤프가 어디에도 보관되지 않습니다. 백업 볼륨을 마운트하세요." + "notPersistent": "{dir}은(는) 영구 저장소에 있지 않고(마운트된 볼륨이 아니라 컨테이너 안이나 메모리에 있음) 외부 버킷도 설정되지 않아, 데이터베이스 덤프가 어디에도 보관되지 않습니다. 백업 볼륨을 마운트하세요.", + "offhostFailedTitle": "최신 데이터베이스 덤프가 외부 버킷에 도달하지 못했습니다", + "offhostLastUpload": "버킷에 있는 최신 덤프는 {when}의 것입니다.", + "offhostNeverUploaded": "아직 버킷에 도달한 데이터베이스 덤프가 없습니다." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "최신 데이터베이스 덤프가 최근 것입니다.", "nowhere_to_write": "덤프 디렉터리 {directory}에 쓸 수 없고 외부 버킷도 설정되어 있지 않아 덤프를 보관할 수 없습니다.", "not_persistent": "덤프 디렉터리 {directory}이(가) 영구 저장소에 있지 않고 외부 버킷도 설정되지 않아, 모든 덤프가 다음 업데이트 때 사라집니다. 백업 볼륨을 마운트하세요.", - "fresh_offhost_only": "최신 데이터베이스 덤프는 최근 것이며 외부 버킷에 있습니다. {directory}이(가) 영구 저장소에 있지 않아 이 서버에는 보관되지 않으며, 마이그레이션 전에도 만들어지지 않습니다." + "fresh_offhost_only": "최신 데이터베이스 덤프는 최근 것이며 외부 버킷에 있습니다. {directory}이(가) 영구 저장소에 있지 않아 이 서버에는 보관되지 않으며, 마이그레이션 전에도 만들어지지 않습니다.", + "offhost_failed": "최신 데이터베이스 덤프는 이 서버에 보관되어 있지만 외부 버킷으로의 업로드가 실패했습니다.", + "offhost_stale": "{hours}시간 동안 외부 버킷에 도달한 데이터베이스 덤프가 없습니다. 업로드가 계속 실패하고 있습니다. 이 서버를 잃으면 복구에 쓸 최근 덤프가 없습니다.", + "offhost_never": "외부 버킷에 도달한 데이터베이스 덤프가 한 번도 없습니다. 업로드가 실패합니다. 이 서버를 잃으면 복구에 쓸 덤프가 없습니다." }, "weekly_copy": { "no_accounts": "아직 복사할 계정이 없습니다.", diff --git a/messages/nl.json b/messages/nl.json index 9bd5560ec..32d63e450 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8348,7 +8348,10 @@ "retentionOffhost": "De levenscyclusregel van de bucket verwijdert externe kopieën na {days} dagen.", "retentionOffhostMissing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe.", "notPersistentOffhost": "{dir} staat niet op blijvende opslag (in de container in plaats van op een gekoppeld volume, of in het geheugen), dus een dump die daar wordt geschreven gaat bij de volgende update verloren. Dumps gaan alleen extern: hier wordt er geen bewaard en vóór een migratie wordt er geen gemaakt. Koppel het back-upvolume.", - "notPersistent": "{dir} staat niet op blijvende opslag (in de container in plaats van op een gekoppeld volume, of in het geheugen) en er is geen externe bucket ingesteld, dus nergens wordt een databasedump bewaard. Koppel het back-upvolume." + "notPersistent": "{dir} staat niet op blijvende opslag (in de container in plaats van op een gekoppeld volume, of in het geheugen) en er is geen externe bucket ingesteld, dus nergens wordt een databasedump bewaard. Koppel het back-upvolume.", + "offhostFailedTitle": "De nieuwste databasedump is niet in de externe bucket aangekomen", + "offhostLastUpload": "De nieuwste dump in de bucket is van {when}.", + "offhostNeverUploaded": "Er is nog geen databasedump in de bucket aangekomen." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "De nieuwste databasedump is recent.", "nowhere_to_write": "De dumpmap {directory} is niet beschrijfbaar en er is geen externe bucket ingesteld, dus er kan geen dump worden bewaard.", "not_persistent": "De dumpmap {directory} staat niet op blijvende opslag en er is geen externe bucket ingesteld, dus elke dump gaat bij de volgende update verloren. Koppel het back-upvolume.", - "fresh_offhost_only": "De nieuwste databasedump is recent en staat in de externe bucket. Op deze server wordt er geen bewaard, omdat {directory} niet op blijvende opslag staat, en vóór een migratie wordt er geen gemaakt." + "fresh_offhost_only": "De nieuwste databasedump is recent en staat in de externe bucket. Op deze server wordt er geen bewaard, omdat {directory} niet op blijvende opslag staat, en vóór een migratie wordt er geen gemaakt.", + "offhost_failed": "De nieuwste databasedump staat op deze server, maar het uploaden naar de externe bucket is mislukt.", + "offhost_stale": "Al {hours} uur is er geen databasedump in de externe bucket aangekomen: het uploaden blijft mislukken. Gaat deze server verloren, dan is er geen recente dump om hem mee te herstellen.", + "offhost_never": "Er is nog nooit een databasedump in de externe bucket aangekomen: het uploaden mislukt. Gaat deze server verloren, dan is er geen dump om hem mee te herstellen." }, "weekly_copy": { "no_accounts": "Er zijn nog geen accounts om te kopiëren.", diff --git a/messages/pl.json b/messages/pl.json index 2a1673bcd..4d51c9924 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8348,7 +8348,10 @@ "retentionOffhost": "Reguła cyklu życia bucketu usuwa kopie zewnętrzne po {days} dniach.", "retentionOffhostMissing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu.", "notPersistentOffhost": "{dir} nie leży na trwałym nośniku (jest wewnątrz kontenera zamiast na podłączonym woluminie albo w pamięci), więc zapisany tam zrzut przepadnie przy następnej aktualizacji. Zrzuty trafiają tylko poza serwer: żaden nie zostaje tutaj i żaden nie powstaje przed migracją. Podłącz wolumin kopii.", - "notPersistent": "{dir} nie leży na trwałym nośniku (jest wewnątrz kontenera zamiast na podłączonym woluminie albo w pamięci), a zewnętrzny bucket nie jest ustawiony, więc żaden zrzut bazy nie jest nigdzie przechowywany. Podłącz wolumin kopii." + "notPersistent": "{dir} nie leży na trwałym nośniku (jest wewnątrz kontenera zamiast na podłączonym woluminie albo w pamięci), a zewnętrzny bucket nie jest ustawiony, więc żaden zrzut bazy nie jest nigdzie przechowywany. Podłącz wolumin kopii.", + "offhostFailedTitle": "Najnowszy zrzut bazy danych nie trafił do zewnętrznego bucketu", + "offhostLastUpload": "Najnowszy zrzut w buckecie pochodzi z {when}.", + "offhostNeverUploaded": "Do bucketu nie trafił jeszcze żaden zrzut bazy danych." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "Najnowszy zrzut bazy danych jest aktualny.", "nowhere_to_write": "Katalog zrzutów {directory} nie jest zapisywalny i nie skonfigurowano zewnętrznego bucketu, więc żaden zrzut nie może zostać zachowany.", "not_persistent": "Katalog zrzutów {directory} nie leży na trwałym nośniku, a zewnętrzny bucket nie jest ustawiony, więc każdy zrzut przepadnie przy następnej aktualizacji. Podłącz wolumin kopii.", - "fresh_offhost_only": "Najnowszy zrzut bazy jest aktualny i leży w zewnętrznym buckecie. Na tym serwerze żaden nie zostaje, bo {directory} nie leży na trwałym nośniku, i żaden nie powstaje przed migracją." + "fresh_offhost_only": "Najnowszy zrzut bazy jest aktualny i leży w zewnętrznym buckecie. Na tym serwerze żaden nie zostaje, bo {directory} nie leży na trwałym nośniku, i żaden nie powstaje przed migracją.", + "offhost_failed": "Najnowszy zrzut bazy danych jest przechowywany na tym serwerze, ale jego przesłanie do zewnętrznego bucketu się nie powiodło.", + "offhost_stale": "Od {hours} godzin do zewnętrznego bucketu nie trafił żaden zrzut bazy danych: przesyłanie wciąż się nie udaje. Jeśli ten serwer przepadnie, nie ma aktualnego zrzutu, z którego można go odbudować.", + "offhost_never": "Żaden zrzut bazy danych nigdy nie trafił do zewnętrznego bucketu: przesyłanie się nie udaje. Jeśli ten serwer przepadnie, nie ma zrzutu, z którego można go odbudować." }, "weekly_copy": { "no_accounts": "Nie ma jeszcze kont do skopiowania.", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index cc011e70a..0beda2f04 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8348,7 +8348,10 @@ "retentionOffhost": "A regra de ciclo de vida do bucket remove as cópias externas após {days} dias.", "retentionOffhostMissing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo.", "notPersistentOffhost": "{dir} não está em armazenamento persistente (fica dentro do contêiner e não num volume montado, ou na memória), então um dump gravado ali se perde na próxima atualização. Os dumps vão só para fora: nenhum fica aqui e nenhum é feito antes de uma migração. Monte o volume de backups.", - "notPersistent": "{dir} não está em armazenamento persistente (fica dentro do contêiner e não num volume montado, ou na memória) e nenhum bucket externo está configurado, então nenhum dump do banco é guardado em lugar nenhum. Monte o volume de backups." + "notPersistent": "{dir} não está em armazenamento persistente (fica dentro do contêiner e não num volume montado, ou na memória) e nenhum bucket externo está configurado, então nenhum dump do banco é guardado em lugar nenhum. Monte o volume de backups.", + "offhostFailedTitle": "O dump mais recente do banco de dados não chegou ao bucket externo", + "offhostLastUpload": "O dump mais recente no bucket é de {when}.", + "offhostNeverUploaded": "Nenhum dump do banco de dados chegou ao bucket ainda." } }, "danger-zone": { @@ -8695,7 +8698,10 @@ "fresh": "O dump do banco de dados mais recente está em dia.", "nowhere_to_write": "Não é possível gravar no diretório de dumps {directory} e nenhum bucket externo está configurado, então nenhum dump pode ser guardado.", "not_persistent": "A pasta de dumps {directory} não está em armazenamento persistente e nenhum bucket externo está configurado, então cada dump se perde na próxima atualização. Monte o volume de backups.", - "fresh_offhost_only": "O dump mais recente do banco é atual e está no bucket externo. Nenhum fica neste servidor, porque {directory} não está em armazenamento persistente, e nenhum é feito antes de uma migração." + "fresh_offhost_only": "O dump mais recente do banco é atual e está no bucket externo. Nenhum fica neste servidor, porque {directory} não está em armazenamento persistente, e nenhum é feito antes de uma migração.", + "offhost_failed": "O dump mais recente do banco de dados está guardado neste servidor, mas o envio para o bucket externo falhou.", + "offhost_stale": "Nenhum dump do banco de dados chegou ao bucket externo em {hours} horas: os envios continuam falhando. Se este servidor for perdido, não há um dump recente para reconstruí-lo.", + "offhost_never": "Nenhum dump do banco de dados jamais chegou ao bucket externo: o envio falha. Se este servidor for perdido, não há dump para reconstruí-lo." }, "weekly_copy": { "no_accounts": "Ainda não há contas para copiar.", diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts index 4a66a2ff0..3726276b9 100644 --- a/src/app/api/admin/backups/__tests__/route.test.ts +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -74,6 +74,7 @@ const databaseHealth = { offhost: true, }, lastFailure: null, + offhost: { lastUploadAt: "2026-09-21T23:31:40.000Z", failure: null }, }; vi.mock("@/lib/database-backup/health", () => ({ getDatabaseBackupHealth: async () => databaseHealth, diff --git a/src/components/admin/backups/__tests__/database-dump-row.test.tsx b/src/components/admin/backups/__tests__/database-dump-row.test.tsx index ac0216c8d..3429403e0 100644 --- a/src/components/admin/backups/__tests__/database-dump-row.test.tsx +++ b/src/components/admin/backups/__tests__/database-dump-row.test.tsx @@ -28,6 +28,7 @@ const HEALTHY: DatabaseBackupHealth = { offhost: true, }, lastFailure: null, + offhost: { lastUploadAt: "2026-10-24T23:31:00.000Z", failure: null }, }; function render( @@ -55,6 +56,36 @@ describe("", () => { expect(html).not.toContain('data-slot="database-dump-no-lifecycle"'); }); + it("says when the newest dump stayed on this server because its upload failed", () => { + const at = "2026-10-25T23:31:00.000Z"; + const html = render({ + ...HEALTHY, + lastGood: { ...HEALTHY.lastGood!, at, offhost: false }, + lastFailure: { at, error: "off-host upload failed: AccessDenied" }, + offhost: { + lastUploadAt: "2026-10-22T23:31:00.000Z", + failure: { at, error: "off-host upload failed: AccessDenied" }, + }, + }); + expect(html).toContain('data-slot="database-dump-offhost-failed"'); + expect(html).toContain("did not reach the off-host bucket"); + expect(html).toContain("AccessDenied"); + expect(html).toContain("The newest dump in the bucket is from"); + // Said once, as the upload it is, not also as a failed dump. + expect(html).not.toContain('data-slot="database-dump-failed"'); + }); + + it("shows a failure at the instant of the newest dump, such as retention throwing", () => { + const at = "2026-10-25T23:31:00.000Z"; + const html = render({ + ...HEALTHY, + lastGood: { ...HEALTHY.lastGood!, at }, + lastFailure: { at, error: "EACCES: permission denied, unlink" }, + }); + expect(html).toContain('data-slot="database-dump-failed"'); + expect(html).not.toContain('data-slot="database-dump-offhost-failed"'); + }); + it("warns when no bucket rule ever removes the dumps", () => { const html = render(HEALTHY, { state: "missing", expirationDays: null }); expect(html).toContain('data-slot="database-dump-no-lifecycle"'); diff --git a/src/components/admin/backups/database-dump-row.tsx b/src/components/admin/backups/database-dump-row.tsx index b848b846e..6f37b2639 100644 --- a/src/components/admin/backups/database-dump-row.tsx +++ b/src/components/admin/backups/database-dump-row.tsx @@ -6,7 +6,8 @@ * 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. A + * newest run failed (or, with a bucket, did not get its dump there), and + * whether this server can keep a dump at all. A * directory inside the container (the image's own, without the volume) is * writable and still keeps nothing past the next update, so "kept on this * server" is only said of a directory that survives it. @@ -16,6 +17,7 @@ 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 { databaseDumpProblems } from "@/lib/database-backup/dump-problems"; import type { DatabaseBackupHealth } from "@/lib/database-backup/health"; import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle"; @@ -41,9 +43,9 @@ export function DatabaseDumpRow({ 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); + const problems = databaseDumpProblems(health); + const failure = problems.failure; + const offhostFailure = problems.offhost; let setupProblem: string | null = null; if (!health.enabled) { @@ -115,7 +117,7 @@ export function DatabaseDumpRow({ - {failedSinceGood && failure ? ( + {failure ? (
) : null} + {offhostFailure ? ( +
+
+
+
+ ) : null} + {bucketLifecycle?.state === "missing" ? ( , +): DatabaseDumpProblems { + const offhost = + health.offhostConfigured && health.offhost.failure + ? { + ...health.offhost.failure, + lastUploadAt: health.offhost.lastUploadAt, + } + : null; + const failure = health.lastFailure; + const good = health.lastGood; + // At or after: a run that kept its dump and then failed is the newest good + // dump and the newest failure, at the same instant. + const sinceGood = + failure !== null && (good === null || failure.at >= good.at); + // That same run failing only its upload is the off-host problem, said once. + const isTheUpload = offhost !== null && failure?.at === offhost.at; + return { failure: sinceGood && !isTheUpload ? failure : null, offhost }; +} diff --git a/src/lib/database-backup/health.ts b/src/lib/database-backup/health.ts index b4f7a4378..de02fc320 100644 --- a/src/lib/database-backup/health.ts +++ b/src/lib/database-backup/health.ts @@ -57,6 +57,20 @@ export interface DatabaseBackupHealth { } | null; /** The newest run that failed, or that a dying process left unfinished. */ lastFailure: { at: string; error: string } | null; + /** + * The off-host leg on its own. A run that kept its dump on the server and + * failed the upload is a good dump and a failed run at once; read only as + * those two, it looked healthy while nothing reached the bucket. + */ + offhost: { + /** When the newest dump reached the bucket; null when none ever did. */ + lastUploadAt: string | null; + /** + * The newest dump that was taken but did not reach the bucket, when no + * later one did. Null while the newest dump taken is in the bucket. + */ + failure: { at: string; error: string } | null; + }; } /** @@ -80,8 +94,8 @@ export async function getDatabaseBackupHealth( const keptWhere = local.persistent ? [{ localResult: "written" }, { offhostResult: "uploaded" }] : [{ offhostResult: "uploaded" }]; - const [lastGood, lastFailed, lastAbandoned, everUploaded] = await Promise.all( - [ + const [lastGood, lastFailed, lastAbandoned, lastUploaded, lastNotUploaded] = + await Promise.all([ prisma.databaseBackupRun.findFirst({ where: { fileName: { not: null }, OR: keptWhere }, orderBy: { startedAt: "desc" }, @@ -107,9 +121,19 @@ export async function getDatabaseBackupHealth( orderBy: { startedAt: "desc" }, select: { startedAt: true }, }), - prisma.databaseBackupRun.count({ where: { offhostResult: "uploaded" } }), - ], - ); + prisma.databaseBackupRun.findFirst({ + where: { offhostResult: "uploaded" }, + orderBy: { startedAt: "desc" }, + select: { startedAt: true, finishedAt: true }, + }), + // A dump was taken (the file name is set only when the run finishes) + // and its upload failed. + prisma.databaseBackupRun.findFirst({ + where: { fileName: { not: null }, offhostResult: "failed" }, + orderBy: { startedAt: "desc" }, + select: { startedAt: true, finishedAt: true, error: true }, + }), + ]); const failures = [ lastFailed && { @@ -131,7 +155,7 @@ export async function getDatabaseBackupHealth( localDirWritable: local.writable, localPersistent: local.persistent, localReason: local.reason, - offhostConfigured: offhostBackupConfigured() || everUploaded > 0, + offhostConfigured: offhostBackupConfigured() || lastUploaded !== null, lastGood: lastGood ? { at: (lastGood.finishedAt ?? lastGood.startedAt).toISOString(), @@ -143,5 +167,22 @@ export async function getDatabaseBackupHealth( } : null, lastFailure: failures[0] ?? null, + offhost: { + lastUploadAt: lastUploaded ? runAt(lastUploaded) : null, + failure: + lastNotUploaded && + (lastUploaded === null || + lastNotUploaded.startedAt > lastUploaded.startedAt) + ? { + at: runAt(lastNotUploaded), + error: lastNotUploaded.error ?? "the upload failed", + } + : null, + }, }; } + +/** When a run ended, or began when it never ended. */ +function runAt(run: { startedAt: Date; finishedAt: Date | null }): string { + return (run.finishedAt ?? run.startedAt).toISOString(); +} diff --git a/src/lib/recovery/__tests__/readiness-model.test.ts b/src/lib/recovery/__tests__/readiness-model.test.ts index b9d59405d..e8e92ab2c 100644 --- a/src/lib/recovery/__tests__/readiness-model.test.ts +++ b/src/lib/recovery/__tests__/readiness-model.test.ts @@ -71,6 +71,7 @@ function dump( error: lastFailureReason ?? "failed", } : null, + offhost: { lastUploadAt: null, failure: null }, ...rest, }; } @@ -166,6 +167,89 @@ describe("database dump", () => { }); }); + // The run that kept its dump here and failed its upload is the newest good + // dump and the newest failure at the same instant; read as only those two, + // it looked fresh while nothing reached the bucket. + it("says when the bucket did not get the newest dump, and fails once its copy there is stale", () => { + const keptAt = ago(2 * HOUR); + const uploadFailed = (lastUploadAt: Date | null) => + dump({ + offhostConfigured: true, + lastGoodAt: keptAt, + lastFailureAt: keptAt, + lastFailureReason: "off-host upload failed: AccessDenied", + offhost: { + lastUploadAt: lastUploadAt?.toISOString() ?? null, + failure: { + at: keptAt.toISOString(), + error: "off-host upload failed: AccessDenied", + }, + }, + }); + expect(evalDump(uploadFailed(ago(26 * HOUR)))).toMatchObject({ + state: "attention", + reason: "offhost_failed", + detail: "off-host upload failed: AccessDenied", + }); + expect( + evalDump(uploadFailed(ago(DUMP_FAILING_AFTER_HOURS * HOUR + 1))), + ).toMatchObject({ + state: "failing", + reason: "offhost_stale", + params: { hours: 36 }, + }); + expect(evalDump(uploadFailed(null))).toMatchObject({ + state: "failing", + reason: "offhost_never", + }); + // A bucket nobody configured is not missing a dump. + expect( + evalDump({ ...uploadFailed(null), offhostConfigured: false }), + ).toMatchObject({ state: "attention", reason: "last_failed" }); + }); + + it("names a failure after the newest dump at the same instant, such as retention throwing", () => { + const keptAt = ago(2 * HOUR); + expect( + evalDump( + dump({ + lastGoodAt: keptAt, + lastFailureAt: keptAt, + lastFailureReason: "EACCES: unlink", + }), + ), + ).toMatchObject({ + state: "attention", + reason: "last_failed", + detail: "EACCES: unlink", + }); + }); + + it("calls an upload-only setup whose upload never worked never succeeded", () => { + const at = ago(HOUR); + expect( + evalDump( + dump({ + localDirWritable: true, + localPersistent: false, + localReason: "container_layer", + offhostConfigured: true, + lastGoodAt: null, + lastFailureAt: at, + offhost: { + lastUploadAt: null, + failure: { at: at.toISOString(), error: "AccessDenied" }, + }, + }), + ago(2 * HOUR), + ), + ).toMatchObject({ + state: "failing", + reason: "never_succeeded", + detail: "AccessDenied", + }); + }); + it("fails when a dump can be kept nowhere, and accepts a bucket-only setup", () => { expect(evalDump(dump({ localDirWritable: false }))).toMatchObject({ state: "failing", diff --git a/src/lib/recovery/readiness-model.ts b/src/lib/recovery/readiness-model.ts index ba7b1fbe8..90b204f2f 100644 --- a/src/lib/recovery/readiness-model.ts +++ b/src/lib/recovery/readiness-model.ts @@ -30,6 +30,7 @@ import { type OffhostBackupFreshness, } from "@/lib/jobs/offhost-backup-freshness"; import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle"; +import { databaseDumpProblems } from "@/lib/database-backup/dump-problems"; import type { DatabaseBackupHealth } from "@/lib/recovery/database-dump-health"; import type { KitEntry } from "@/lib/recovery/kit-rules"; @@ -202,8 +203,9 @@ export function evaluateDatabaseDump( ? signal(id, "inactive", "disabled", null) : signal(id, "attention", "disabled", lastGoodAt); } - const lastFailureAt = dump.lastFailure ? new Date(dump.lastFailure.at) : null; - const failureReason = dump.lastFailure?.error ?? null; + const problems = databaseDumpProblems(dump); + const lastFailureAt = problems.failure ? new Date(problems.failure.at) : null; + const failureReason = problems.failure?.error ?? null; // A directory that keeps nothing (not writable, or writable inside the // container and lost at the next update) is a valid setup when the dumps // go to the bucket; with no bucket either, no dump is kept anywhere. @@ -217,18 +219,19 @@ export function evaluateDatabaseDump( ); } const limitMs = DUMP_FAILING_AFTER_HOURS * HOUR_MS; - const failedSinceGood = - lastFailureAt !== null && - (lastGoodAt === null || lastFailureAt > lastGoodAt); + const failedSinceGood = lastFailureAt !== null; if (lastGoodAt === null) { - if (failedSinceGood) { + // Nothing kept anywhere yet; a dump that only failed its upload (no + // directory that survives) has not succeeded either. + const failed = problems.failure ?? problems.offhost; + if (failed) { return signal( id, "failing", "never_succeeded", - lastFailureAt, + new Date(failed.at), {}, - failureReason, + failed.error, ); } const watched = input.dumpWatchedSince ?? now; @@ -249,6 +252,43 @@ export function evaluateDatabaseDump( failedSinceGood ? failureReason : null, ); } + // The dump is kept on this server, and the bucket configured for it did + // not get it: the copy that outlives the host is missing. Past the same + // limit as a stale dump, no copy off the host is recent enough to rebuild + // from. + if (problems.offhost) { + const uploadedAt = problems.offhost.lastUploadAt + ? new Date(problems.offhost.lastUploadAt) + : null; + if (uploadedAt === null) { + return signal( + id, + "failing", + "offhost_never", + lastGoodAt, + {}, + problems.offhost.error, + ); + } + const uploadAgeMs = now.getTime() - uploadedAt.getTime(); + return uploadAgeMs > limitMs + ? signal( + id, + "failing", + "offhost_stale", + lastGoodAt, + { hours: wholeHours(uploadAgeMs) }, + problems.offhost.error, + ) + : signal( + id, + "attention", + "offhost_failed", + lastGoodAt, + {}, + problems.offhost.error, + ); + } if (failedSinceGood) { return signal( id, diff --git a/tests/integration/database-backup.test.ts b/tests/integration/database-backup.test.ts index 0213dc4bb..8c889cc4c 100644 --- a/tests/integration/database-backup.test.ts +++ b/tests/integration/database-backup.test.ts @@ -29,6 +29,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { prisma } from "@/lib/db"; import { getDatabaseBackupHealth } from "@/lib/database-backup/health"; +import { evaluateDatabaseDump } from "@/lib/recovery/readiness-model"; import { runDatabaseBackup } from "@/lib/database-backup/run"; import { connect, @@ -391,6 +392,61 @@ describe("the nightly database dump", () => { }); }); + // A dump kept on the volume and refused by the bucket is the newest good + // dump and the newest failure at once. Read as only those two it looked + // healthy, night after night, while nothing left the host. + it("does not read a kept dump as healthy when the bucket refused it", async () => { + const { mkdirSync } = await import("node:fs"); + const dir = join(work, "upload-refused"); + mkdirSync(dir); + vi.stubEnv("BACKUP_S3_ENDPOINT", "https://bucket.invalid"); + vi.stubEnv("BACKUP_S3_BUCKET", "b"); + vi.stubEnv("BACKUP_S3_ACCESS_KEY", "a"); + vi.stubEnv("BACKUP_S3_SECRET_KEY", "s"); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", OFFHOST_KEY); + const refusing = { + putStream: async (_key: string, body: Readable) => { + for await (const _ of body) void _; + throw new Error("AccessDenied on database/"); + }, + }; + const report = await runDatabaseBackup(prisma, { + now: new Date("2026-10-24T07:00:00Z"), + env: backupEnv({ DB_BACKUP_DIR: dir }), + s3: refusing as never, + }); + vi.unstubAllEnvs(); + expect(report).toMatchObject({ + status: "failed", + local: "written", + offhost: "failed", + }); + + const health = await getDatabaseBackupHealth( + new Date(), + backupEnv({ DB_BACKUP_DIR: dir }), + ); + expect(health.lastGood).toMatchObject({ + fileName: "healthlog-20261024T070000Z-daily.dump", + local: true, + offhost: false, + }); + expect(health.offhostConfigured).toBe(true); + expect(health.offhost.lastUploadAt).not.toBeNull(); + expect(health.offhost.failure?.error).toMatch(/AccessDenied/); + expect( + evaluateDatabaseDump({ + now: new Date(), + dump: health, + dumpWatchedSince: null, + }), + ).toMatchObject({ + state: "attention", + reason: "offhost_failed", + detail: expect.stringMatching(/AccessDenied/), + }); + }); + describe("restoring it", () => { const target = `restore_${process.pid}_${Date.now()}`; let targetUrl = ""; diff --git a/tests/integration/recovery-readiness-dump-retention.test.ts b/tests/integration/recovery-readiness-dump-retention.test.ts index f7a2f9c9b..b2f0eeb3b 100644 --- a/tests/integration/recovery-readiness-dump-retention.test.ts +++ b/tests/integration/recovery-readiness-dump-retention.test.ts @@ -44,6 +44,10 @@ const dumpGoingOffhost = async (): Promise => ({ offhost: true, }, lastFailure: null, + offhost: { + lastUploadAt: new Date(NOW.getTime() - 3_600_000).toISOString(), + failure: null, + }, }); function useBucket(): void {