Просмотр исходного кода

Add backup status card and pre-deploy backup workflows (#233)

* add backup info

* backup
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
88c1b3e95e

+ 3 - 1
.github/workflows/deploy-cloud.yml

@@ -29,11 +29,12 @@ jobs:
           DATA_PATH: ${{ secrets.DATA_PATH }}
           POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
+          BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
         run: |
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -63,6 +64,7 @@ jobs:
                 STRIPE_ENTERPRISE_PRICE_ID: ${STRIPE_ENTERPRISE_PRICE_ID}
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 POSTHOG_KEY: ${POSTHOG_KEY}
+                BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
               networks:
                 - proxy
           COMPOSE

+ 37 - 0
.github/workflows/deploy-staging.yml

@@ -87,6 +87,43 @@ jobs:
             exit 1
           fi
 
+      - name: Back up staging database
+        env:
+          DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
+        run: |
+          set -euo pipefail
+
+          # Credentials come from the secret rather than being hardcoded, so this
+          # step can be copied to prod unchanged.
+          DB_USER=$(echo "$DATABASE_URL" | sed -E 's|^[^:]+://([^:]+):.*|\1|')
+          DB_NAME=$(echo "$DATABASE_URL" | sed -E 's|.*/([^/?]+)(\?.*)?$|\1|')
+
+          BACKUP_DIR="${{ secrets.DATA_PATH }}/db-backups/staging"
+          mkdir -p "$BACKUP_DIR"
+
+          # The Postgres data directory lives on this same filesystem, so filling
+          # it takes the database down rather than merely losing the backup.
+          # Refuse to dump unless there is comfortable headroom.
+          MIN_GB=2
+          AVAIL_KB=$(df -Pk "$BACKUP_DIR" | awk 'NR==2 {print $4}')
+          if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
+            echo "::error::Only $(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}") GB free where the database lives. Refusing to write a dump."
+            exit 1
+          fi
+
+          DUMP="$BACKUP_DIR/${DB_NAME}-$(date +%Y%m%d-%H%M%S)-pre-${{ inputs.tag || 'latest' }}.dump"
+
+          echo "Dumping $DB_NAME to $DUMP"
+          docker exec torqvoice-db pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc > "$DUMP"
+
+          # A dump nobody can read is worse than no dump, because you think you
+          # have one. Fail the deploy rather than proceed on a bad backup.
+          docker exec -i torqvoice-db pg_restore --list < "$DUMP" > /dev/null
+          echo "Verified $(du -h "$DUMP" | cut -f1) backup"
+
+          # Keep the 10 most recent.
+          ls -t "$BACKUP_DIR"/*.dump 2>/dev/null | tail -n +11 | xargs -r rm --
+
       - name: Deploy staging
         run: |
           cd $HOME/torqvoice-deploy/staging

+ 115 - 0
.github/workflows/rollback-staging.yml

@@ -0,0 +1,115 @@
+name: "Rollback: Staging"
+
+# Rolls staging back to an earlier image, and optionally restores the database
+# from a dump taken by the deploy workflow.
+#
+# Rolling the image back is the normal case and loses nothing. Restoring the
+# database is a last resort for when the new schema is incompatible with the
+# old code, and it discards everything written since the dump was taken.
+#
+# Reuses the compose file and .env that "Deploy: Staging" wrote, so staging
+# must have been deployed at least once before this can run.
+
+on:
+  workflow_dispatch:
+    inputs:
+      tag:
+        description: "Image tag to roll back to (e.g. v1.2.38, dev-abc1234)"
+        required: true
+      restore_database:
+        description: "DESTRUCTIVE. Also restore the database, discarding everything written since the dump. Leave off unless the old code cannot run against the new schema."
+        type: boolean
+        default: false
+      dump:
+        description: "Dump filename to restore from (e.g. torqvoice-staging-20260813-191500-pre-v1.2.39.dump). Only used when restore_database is on."
+        required: false
+        default: ""
+
+jobs:
+  rollback:
+    runs-on: [self-hosted, Linux, X64, hetzner]
+
+    steps:
+      - name: Show available dumps
+        run: ls -lht "${{ secrets.DATA_PATH }}/db-backups/staging" 2>/dev/null | head -20 || echo "No dumps yet."
+
+      # Everything that can fail is checked before the app is stopped, so a bad
+      # input leaves staging untouched and still running.
+      # The restore step drops a database whose name comes from a secret. If that
+      # secret is ever wrong, "rollback staging" silently becomes "destroy prod".
+      # Refuse to proceed unless the target is unmistakably staging.
+      - name: Refuse to run against a non-staging database
+        if: inputs.restore_database
+        env:
+          DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
+        run: |
+          set -euo pipefail
+          DB_NAME=$(echo "$DATABASE_URL" | sed -E 's|.*/([^/?]+)(\?.*)?$|\1|')
+          case "$DB_NAME" in
+            *staging*)
+              echo "Target database: $DB_NAME"
+              ;;
+            *)
+              echo "::error::Refusing to drop \"$DB_NAME\": name does not contain 'staging'."
+              exit 1
+              ;;
+          esac
+
+      - name: Validate dump
+        if: inputs.restore_database
+        run: |
+          set -euo pipefail
+          if [ -z "${{ inputs.dump }}" ]; then
+            echo "::error::restore_database is on but no dump filename was given."
+            exit 1
+          fi
+          DUMP="${{ secrets.DATA_PATH }}/db-backups/staging/${{ inputs.dump }}"
+          if [ ! -f "$DUMP" ]; then
+            echo "::error::Dump not found: $DUMP"
+            exit 1
+          fi
+          docker exec -i torqvoice-db pg_restore --list < "$DUMP" > /dev/null
+          echo "Dump is readable: $DUMP"
+
+      - name: Stop staging app
+        run: |
+          cd $HOME/torqvoice-deploy/staging
+          docker compose stop torqvoice-app-staging
+
+      - name: Restore database
+        if: inputs.restore_database
+        env:
+          DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
+        run: |
+          set -euo pipefail
+          DUMP="${{ secrets.DATA_PATH }}/db-backups/staging/${{ inputs.dump }}"
+          DB_USER=$(echo "$DATABASE_URL" | sed -E 's|^[^:]+://([^:]+):.*|\1|')
+          DB_NAME=$(echo "$DATABASE_URL" | sed -E 's|.*/([^/?]+)(\?.*)?$|\1|')
+
+          echo "Restoring $DB_NAME from $(basename "$DUMP")"
+          # Quoted because database names may contain hyphens.
+          docker exec -i torqvoice-db psql -U "$DB_USER" -d postgres -c "DROP DATABASE \"$DB_NAME\" WITH (FORCE);"
+          docker exec -i torqvoice-db psql -U "$DB_USER" -d postgres -c "CREATE DATABASE \"$DB_NAME\" OWNER \"$DB_USER\";"
+          docker exec -i torqvoice-db pg_restore -U "$DB_USER" -d "$DB_NAME" --no-owner < "$DUMP"
+          echo "Restore complete."
+
+      - name: Deploy previous tag
+        run: |
+          cd $HOME/torqvoice-deploy/staging
+          APP_TAG=${{ inputs.tag }} docker compose up -d --pull always
+
+      - name: Health check
+        run: |
+          echo "Waiting for staging container to be ready..."
+          for i in $(seq 1 12); do
+            STATUS=$(docker inspect --format='{{.State.Status}}' torqvoice-app-staging 2>/dev/null || echo "not found")
+            if [ "$STATUS" = "running" ]; then
+              echo "Rolled back to ${{ inputs.tag }}."
+              exit 0
+            fi
+            echo "Status: $STATUS, retrying in 5s..."
+            sleep 5
+          done
+          echo "::error::Container failed to start after rollback"
+          docker logs torqvoice-app-staging --tail 50
+          exit 1

+ 8 - 1
messages/de/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Dokumente & Anhange",
     "invoiceNinjaPayments": "Zahlungen",
     "invoiceNinjaFileHint": "Laden Sie eine Invoice Ninja ZIP-Exportdatei hoch. Dateien bis 200MB unterstutzt.",
-    "invoiceNinjaImportComplete": "Invoice Ninja-Import abgeschlossen"
+    "invoiceNinjaImportComplete": "Invoice Ninja-Import abgeschlossen",
+    "backupStatus": {
+      "title": "Datenschutz & Sicherung",
+      "description": "Wir nehmen Ihre Daten ernst. Alle Daten Ihrer Organisation, einschließlich Kunden, Fahrzeugen, Arbeitsaufträgen, Rechnungen und hochgeladenen Dateien, werden automatisch stündlich gesichert. Backups werden mit AES-256 verschlüsselt, bevor sie unsere Infrastruktur verlassen, und an einem separaten, sicheren Ort gespeichert, sodass kein Außenstehender sie lesen kann.",
+      "lastBackup": "Letztes Backup:",
+      "supportPrompt": "Bei Fragen zum Schutz Ihrer Daten",
+      "supportAction": "kontaktieren Sie unser Support-Team."
+    }
   },
   "license": {
     "title": "White-Label-Lizenz",

+ 8 - 1
messages/en/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Documents & attachments",
     "invoiceNinjaPayments": "Payments",
     "invoiceNinjaFileHint": "Upload an Invoice Ninja zip export file. Files up to 200MB supported.",
-    "invoiceNinjaImportComplete": "Invoice Ninja Import Complete"
+    "invoiceNinjaImportComplete": "Invoice Ninja Import Complete",
+    "backupStatus": {
+      "title": "Data Protection",
+      "description": "We take your data seriously. All of your organization's data, including customers, vehicles, work orders, invoices and uploaded files, is automatically backed up every hour. Backups are protected with AES-256 encryption before they leave our infrastructure and are stored at a separate, secure location, so no outside party can read them.",
+      "lastBackup": "Last backup:",
+      "supportPrompt": "If you have any questions about how your data is protected,",
+      "supportAction": "contact our support team."
+    }
   },
   "license": {
     "title": "White-Label License",

+ 8 - 1
messages/es/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Documentos y adjuntos",
     "invoiceNinjaPayments": "Pagos",
     "invoiceNinjaFileHint": "Suba un archivo de exportacion zip de Invoice Ninja. Archivos hasta 200MB soportados.",
-    "invoiceNinjaImportComplete": "Importacion de Invoice Ninja completada"
+    "invoiceNinjaImportComplete": "Importacion de Invoice Ninja completada",
+    "backupStatus": {
+      "title": "Protección de datos",
+      "description": "Nos tomamos sus datos en serio. Todos los datos de su organización, incluidos clientes, vehículos, órdenes de trabajo, facturas y archivos subidos, se respaldan automáticamente cada hora. Las copias de seguridad se protegen con cifrado AES-256 antes de salir de nuestra infraestructura y se almacenan en una ubicación separada y segura, de modo que ningún tercero puede leerlas.",
+      "lastBackup": "Última copia de seguridad:",
+      "supportPrompt": "Si tiene alguna pregunta sobre cómo se protegen sus datos,",
+      "supportAction": "contacte con nuestro equipo de soporte."
+    }
   },
   "license": {
     "title": "Licencia marca blanca",

+ 8 - 1
messages/fr/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Documents et pieces jointes",
     "invoiceNinjaPayments": "Paiements",
     "invoiceNinjaFileHint": "Telechargez un fichier d'export zip Invoice Ninja. Fichiers jusqu'a 200 Mo supportes.",
-    "invoiceNinjaImportComplete": "Importation Invoice Ninja terminee"
+    "invoiceNinjaImportComplete": "Importation Invoice Ninja terminee",
+    "backupStatus": {
+      "title": "Protection des données",
+      "description": "Nous prenons vos données au sérieux. Toutes les données de votre organisation, y compris les clients, les véhicules, les ordres de travail, les factures et les fichiers téléversés, sont sauvegardées automatiquement toutes les heures. Les sauvegardes sont protégées par un chiffrement AES-256 avant de quitter notre infrastructure et stockées dans un emplacement séparé et sécurisé, de sorte qu'aucun tiers ne peut les lire.",
+      "lastBackup": "Dernière sauvegarde :",
+      "supportPrompt": "Pour toute question sur la protection de vos données,",
+      "supportAction": "contactez notre équipe d'assistance."
+    }
   },
   "license": {
     "title": "Licence marque blanche",

+ 8 - 1
messages/it/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Documenti e allegati",
     "invoiceNinjaPayments": "Pagamenti",
     "invoiceNinjaFileHint": "Carica un file di esportazione zip di Invoice Ninja. File fino a 200MB supportati.",
-    "invoiceNinjaImportComplete": "Importazione Invoice Ninja completata"
+    "invoiceNinjaImportComplete": "Importazione Invoice Ninja completata",
+    "backupStatus": {
+      "title": "Protezione dei dati",
+      "description": "Prendiamo sul serio i tuoi dati. Tutti i dati della tua organizzazione, inclusi clienti, veicoli, ordini di lavoro, fatture e file caricati, vengono salvati automaticamente ogni ora. I backup sono protetti con crittografia AES-256 prima di lasciare la nostra infrastruttura e archiviati in una posizione separata e sicura, quindi nessuna parte esterna può leggerli.",
+      "lastBackup": "Ultimo backup:",
+      "supportPrompt": "Per qualsiasi domanda sulla protezione dei tuoi dati,",
+      "supportAction": "contatta il nostro team di supporto."
+    }
   },
   "license": {
     "title": "Licenza White-Label",

+ 8 - 1
messages/lt/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Dokumentai ir priedai",
     "invoiceNinjaPayments": "Mokėjimai",
     "invoiceNinjaFileHint": "Įkelkite Invoice Ninja zip eksporto failą. Palaikomi failai iki 200MB.",
-    "invoiceNinjaImportComplete": "Invoice Ninja importas baigtas"
+    "invoiceNinjaImportComplete": "Invoice Ninja importas baigtas",
+    "backupStatus": {
+      "title": "Duomenų apsauga",
+      "description": "Į jūsų duomenis žiūrime rimtai. Visi jūsų organizacijos duomenys, įskaitant klientus, transporto priemones, darbo užsakymus, sąskaitas ir įkeltus failus, automatiškai kopijuojami kas valandą. Atsarginės kopijos apsaugomos AES-256 šifravimu prieš palikdamos mūsų infrastruktūrą ir saugomos atskiroje, saugioje vietoje, todėl joks pašalinis asmuo negali jų perskaityti.",
+      "lastBackup": "Paskutinė atsarginė kopija:",
+      "supportPrompt": "Jei turite klausimų apie jūsų duomenų apsaugą,",
+      "supportAction": "susisiekite su mūsų pagalbos komanda."
+    }
   },
   "license": {
     "title": "\"White-Label\" licencija",

+ 8 - 1
messages/nb/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Dokumenter og vedlegg",
     "invoiceNinjaPayments": "Betalinger",
     "invoiceNinjaFileHint": "Last opp en Invoice Ninja zip-eksportfil. Filer opp til 200MB stottet.",
-    "invoiceNinjaImportComplete": "Invoice Ninja-import fullfort"
+    "invoiceNinjaImportComplete": "Invoice Ninja-import fullfort",
+    "backupStatus": {
+      "title": "Databeskyttelse",
+      "description": "Vi tar dataene dine på alvor. Alle organisasjonens data, inkludert kunder, kjøretøy, arbeidsordrer, fakturaer og opplastede filer, sikkerhetskopieres automatisk hver time. Sikkerhetskopiene beskyttes med AES-256-kryptering før de forlater infrastrukturen vår og lagres på et separat, sikkert sted, slik at ingen utenforstående kan lese dem.",
+      "lastBackup": "Siste sikkerhetskopi:",
+      "supportPrompt": "Har du spørsmål om hvordan dataene dine beskyttes,",
+      "supportAction": "kontakt supportteamet vårt."
+    }
   },
   "license": {
     "title": "White-label-lisens",

+ 8 - 1
messages/nl/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Documenten en bijlagen",
     "invoiceNinjaPayments": "Betalingen",
     "invoiceNinjaFileHint": "Upload een Invoice Ninja zip-exportbestand. Bestanden tot 200MB ondersteund.",
-    "invoiceNinjaImportComplete": "Invoice Ninja-import voltooid"
+    "invoiceNinjaImportComplete": "Invoice Ninja-import voltooid",
+    "backupStatus": {
+      "title": "Gegevensbescherming",
+      "description": "Wij nemen uw gegevens serieus. Alle gegevens van uw organisatie, waaronder klanten, voertuigen, werkorders, facturen en geüploade bestanden, worden elk uur automatisch geback-upt. Back-ups worden beschermd met AES-256-versleuteling voordat ze onze infrastructuur verlaten en opgeslagen op een aparte, veilige locatie, zodat geen enkele buitenstaander ze kan lezen.",
+      "lastBackup": "Laatste back-up:",
+      "supportPrompt": "Heeft u vragen over hoe uw gegevens worden beschermd,",
+      "supportAction": "neem contact op met ons supportteam."
+    }
   },
   "license": {
     "title": "White-label licentie",

+ 8 - 1
messages/pl/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Dokumenty i zalaczniki",
     "invoiceNinjaPayments": "Platnosci",
     "invoiceNinjaFileHint": "Przeslij plik eksportu zip Invoice Ninja. Obslugiwane pliki do 200MB.",
-    "invoiceNinjaImportComplete": "Import z Invoice Ninja zakonczony"
+    "invoiceNinjaImportComplete": "Import z Invoice Ninja zakonczony",
+    "backupStatus": {
+      "title": "Ochrona danych",
+      "description": "Poważnie traktujemy Twoje dane. Wszystkie dane Twojej organizacji, w tym klienci, pojazdy, zlecenia, faktury i przesłane pliki, są automatycznie kopiowane co godzinę. Kopie zapasowe są chronione szyfrowaniem AES-256, zanim opuszczą naszą infrastrukturę, i przechowywane w oddzielnej, bezpiecznej lokalizacji, więc nikt z zewnątrz nie może ich odczytać.",
+      "lastBackup": "Ostatnia kopia zapasowa:",
+      "supportPrompt": "Jeśli masz pytania dotyczące ochrony Twoich danych,",
+      "supportAction": "skontaktuj się z naszym zespołem wsparcia."
+    }
   },
   "license": {
     "title": "Licencja white-label",

+ 8 - 1
messages/pt-BR/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Documentos e anexos",
     "invoiceNinjaPayments": "Pagamentos",
     "invoiceNinjaFileHint": "Envie um arquivo de exportacao zip do Invoice Ninja. Arquivos ate 200MB suportados.",
-    "invoiceNinjaImportComplete": "Importacao do Invoice Ninja concluida"
+    "invoiceNinjaImportComplete": "Importacao do Invoice Ninja concluida",
+    "backupStatus": {
+      "title": "Proteção de dados",
+      "description": "Levamos seus dados a sério. Todos os dados da sua organização, incluindo clientes, veículos, ordens de serviço, faturas e arquivos enviados, passam por backup automático a cada hora. Os backups são protegidos com criptografia AES-256 antes de sair da nossa infraestrutura e armazenados em um local separado e seguro, de modo que nenhuma parte externa pode lê-los.",
+      "lastBackup": "Último backup:",
+      "supportPrompt": "Se tiver dúvidas sobre como seus dados são protegidos,",
+      "supportAction": "fale com nossa equipe de suporte."
+    }
   },
   "license": {
     "title": "Licenca marca branca",

+ 8 - 1
messages/ru/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Документы и вложения",
     "invoiceNinjaPayments": "Платежи",
     "invoiceNinjaFileHint": "Загрузите zip-экспорт Invoice Ninja. Поддерживаются файлы до 200МБ.",
-    "invoiceNinjaImportComplete": "Импорт Invoice Ninja завершён"
+    "invoiceNinjaImportComplete": "Импорт Invoice Ninja завершён",
+    "backupStatus": {
+      "title": "Защита данных",
+      "description": "Мы серьёзно относимся к вашим данным. Все данные вашей организации, включая клиентов, автомобили, заказ-наряды, счета и загруженные файлы, автоматически резервируются каждый час. Резервные копии защищаются шифрованием AES-256 до того, как покинут нашу инфраструктуру, и хранятся в отдельном надёжном месте, поэтому никакая внешняя сторона не может их прочитать.",
+      "lastBackup": "Последняя резервная копия:",
+      "supportPrompt": "Если у вас есть вопросы о защите ваших данных,",
+      "supportAction": "свяжитесь с нашей службой поддержки."
+    }
   },
   "license": {
     "title": "White-Label лицензия",

+ 8 - 1
messages/tr/settings.json

@@ -940,7 +940,14 @@
     "invoiceNinjaDocuments": "Belgeler & ekler",
     "invoiceNinjaPayments": "Ödemeler",
     "invoiceNinjaFileHint": "Bir Invoice Ninja zip dışa aktarma dosyası yükleyin. 200MB'a kadar dosyalar desteklenir.",
-    "invoiceNinjaImportComplete": "Invoice Ninja İçe Aktarma Tamamlandı"
+    "invoiceNinjaImportComplete": "Invoice Ninja İçe Aktarma Tamamlandı",
+    "backupStatus": {
+      "title": "Veri Koruması",
+      "description": "Verilerinizi ciddiye alıyoruz. Müşteriler, araçlar, iş emirleri, faturalar ve yüklenen dosyalar dahil kuruluşunuzun tüm verileri her saat otomatik olarak yedeklenir. Yedekler altyapımızdan ayrılmadan önce AES-256 şifrelemesiyle korunur ve ayrı, güvenli bir konumda saklanır; bu nedenle hiçbir dış taraf bunları okuyamaz.",
+      "lastBackup": "Son yedekleme:",
+      "supportPrompt": "Verilerinizin nasıl korunduğuyla ilgili sorularınız varsa,",
+      "supportAction": "destek ekibimizle iletişime geçin."
+    }
   },
   "license": {
     "title": "Beyaz Etiket Lisansı",

+ 64 - 5
src/app/(authenticated)/settings/data/data-settings.tsx

@@ -1,7 +1,7 @@
 'use client'
 
 import { useRef, useState } from 'react'
-import { useTranslations } from 'next-intl'
+import { useFormatter, useNow, useTranslations } from 'next-intl'
 import Image from 'next/image'
 import { Button } from '@/components/ui/button'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -9,8 +9,13 @@ import { Checkbox } from '@/components/ui/checkbox'
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { Input } from '@/components/ui/input'
 import { useGlassModal } from '@/components/glass-modal'
-import { AlertTriangle, ArrowRight, Download, FileArchive, Loader2, Trash2, Upload } from 'lucide-react'
+import { AlertTriangle, ArrowRight, Download, FileArchive, Loader2, ShieldCheck, Trash2, Upload } from 'lucide-react'
 import { ReadOnlyBanner, ReadOnlyWrapper } from '../read-only-guard'
+import {
+  SUPPORT_OPEN_EVENT,
+  isSupportBubbleHidden,
+  setSupportBubbleHidden,
+} from '@/features/support/Lib/supportVisibility'
 import { deleteContent } from '@/features/settings/Actions/deleteContent'
 import { toast } from 'sonner'
 import { useRouter } from 'next/navigation'
@@ -73,8 +78,17 @@ const ALL_TRUE: ExportOptions = {
   files: true,
 }
 
-export function DataSettings({ contentCounts }: { contentCounts: ContentCounts }) {
+export function DataSettings({
+  contentCounts,
+  lastBackupAt = null,
+}: {
+  contentCounts: ContentCounts
+  lastBackupAt?: string | null
+}) {
   const t = useTranslations('settings')
+  const format = useFormatter()
+  // Keeps the "Last backup: x ago" label ticking without a reload.
+  const now = useNow({ updateInterval: 60_000 })
   const router = useRouter()
   const modal = useGlassModal()
   const fileInputRef = useRef<HTMLInputElement>(null)
@@ -313,6 +327,51 @@ export function DataSettings({ contentCounts }: { contentCounts: ContentCounts }
         </p>
       </div>
 
+      {lastBackupAt && (() => {
+        const ageHours = (now.getTime() - new Date(lastBackupAt).getTime()) / 3_600_000
+        // Hourly schedule: green while fresh, amber once a couple of runs were
+        // missed, red when a whole day has passed.
+        const dot = ageHours < 3 ? 'bg-emerald-500' : ageHours < 26 ? 'bg-amber-500' : 'bg-red-500'
+        return (
+          <Card className="gap-2 border border-primary/30 shadow-sm">
+            <CardHeader>
+              <CardTitle className="flex items-center gap-2 text-base">
+                <ShieldCheck className="h-4 w-4" /> {t('data.backupStatus.title')}
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="space-y-2">
+              <p className="text-sm text-muted-foreground">
+                {t('data.backupStatus.description')}
+              </p>
+              <p className="flex items-center gap-2 text-sm font-medium">
+                <span className={`inline-block h-2 w-2 rounded-full ${dot}`} />
+                {t('data.backupStatus.lastBackup')}{' '}
+                <span suppressHydrationWarning>
+                  {format.relativeTime(new Date(lastBackupAt), now)}
+                </span>
+              </p>
+              <p className="text-xs text-muted-foreground">
+                {t('data.backupStatus.supportPrompt')}{' '}
+                <button
+                  type="button"
+                  className="underline underline-offset-2 hover:text-foreground"
+                  onClick={() => {
+                    // Opening while hidden would dispatch into nothing, so make
+                    // it visible first and let the widget mount before opening.
+                    if (isSupportBubbleHidden()) {
+                      setSupportBubbleHidden(false)
+                    }
+                    requestAnimationFrame(() => window.dispatchEvent(new Event(SUPPORT_OPEN_EVENT)))
+                  }}
+                >
+                  {t('data.backupStatus.supportAction')}
+                </button>
+              </p>
+            </CardContent>
+          </Card>
+        )
+      })()}
+
       <ReadOnlyWrapper>
         <div className="grid gap-6 lg:grid-cols-12">
           {/* Export Card */}
@@ -434,7 +493,7 @@ export function DataSettings({ contentCounts }: { contentCounts: ContentCounts }
                   alt="LubeLog"
                   width={120}
                   height={30}
-                  className="h-auto object-contain"
+                  className="w-[120px] h-auto object-contain"
                   unoptimized
                 />
               </button>
@@ -450,7 +509,7 @@ export function DataSettings({ contentCounts }: { contentCounts: ContentCounts }
                   alt="Invoice Ninja"
                   width={140}
                   height={30}
-                  className="h-auto object-contain"
+                  className="w-[140px] h-auto object-contain"
                   unoptimized
                 />
               </button>

+ 3 - 1
src/app/(authenticated)/settings/data/page.tsx

@@ -1,7 +1,9 @@
 import { DataSettings } from "./data-settings";
 import { getContentCounts } from "@/features/settings/Actions/deleteContent";
+import { getBackupHeartbeat } from "@/lib/backup-heartbeat";
 
 export default async function DataSettingsPage() {
+  const heartbeat = await getBackupHeartbeat();
   const countsResult = await getContentCounts();
   const contentCounts = countsResult.success && countsResult.data
     ? countsResult.data
@@ -11,5 +13,5 @@ export default async function DataSettingsPage() {
         notifications: 0, smsMessages: 0, customFields: 0,
       };
 
-  return <DataSettings contentCounts={contentCounts} />;
+  return <DataSettings contentCounts={contentCounts} lastBackupAt={heartbeat?.at ?? null} />;
 }

+ 54 - 0
src/app/api/system/backup-heartbeat/route.ts

@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import { createHash, timingSafeEqual } from "crypto";
+import { db } from "@/lib/db";
+import { BACKUP_HEARTBEAT_KEY } from "@/lib/backup-heartbeat";
+import { rateLimit } from "@/lib/rate-limit";
+
+/**
+ * Called by the off-app backup job after each successful offsite push, so the
+ * settings UI can show organizations how fresh the latest backup is. Not tied
+ * to any organization: the backup covers the whole installation, so a single
+ * system-wide timestamp is the truthful representation.
+ *
+ * Auth is a static bearer token (BACKUP_HEARTBEAT_TOKEN). When the env var is
+ * absent the endpoint plays dead with a 404, so installations that never
+ * configure it (e.g. self-hosted without the backup job) expose nothing.
+ */
+export async function POST(request: NextRequest) {
+  const expected = process.env.BACKUP_HEARTBEAT_TOKEN;
+  if (!expected) {
+    return NextResponse.json({ error: "Not found" }, { status: 404 });
+  }
+
+  const provided = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
+  // Hashing both sides gives equal-length buffers, which timingSafeEqual requires.
+  const providedHash = createHash("sha256").update(provided).digest();
+  const expectedHash = createHash("sha256").update(expected).digest();
+  if (!timingSafeEqual(providedHash, expectedHash)) {
+    // Only failed auth is rate limited. The bucket key (X-Forwarded-For) is
+    // spoofable, so limiting before auth would let strangers fill the real
+    // backup job's bucket and block the legitimate hourly heartbeat.
+    const limited = rateLimit(request, { limit: 5, windowMs: 60_000 });
+    if (limited) return limited;
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  let snapshotId: string | null = null;
+  try {
+    const body = await request.json();
+    if (typeof body?.snapshotId === "string" && body.snapshotId.length <= 64) {
+      snapshotId = body.snapshotId;
+    }
+  } catch {
+    // Body is optional; the timestamp is the payload that matters.
+  }
+
+  const value = JSON.stringify({ at: new Date().toISOString(), snapshotId });
+  await db.systemSetting.upsert({
+    where: { key: BACKUP_HEARTBEAT_KEY },
+    create: { key: BACKUP_HEARTBEAT_KEY, value },
+    update: { value },
+  });
+
+  return new NextResponse(null, { status: 204 });
+}

+ 26 - 0
src/lib/backup-heartbeat.ts

@@ -0,0 +1,26 @@
+import { db } from "@/lib/db";
+
+export const BACKUP_HEARTBEAT_KEY = "backup.heartbeat";
+
+export interface BackupHeartbeat {
+  at: string;
+  snapshotId: string | null;
+}
+
+/**
+ * Latest offsite-backup heartbeat, or null when the installation has never
+ * reported one (e.g. self-hosted without the backup job configured).
+ */
+export async function getBackupHeartbeat(): Promise<BackupHeartbeat | null> {
+  const row = await db.systemSetting.findUnique({
+    where: { key: BACKUP_HEARTBEAT_KEY },
+  });
+  if (!row) return null;
+  try {
+    const parsed = JSON.parse(row.value);
+    if (typeof parsed?.at !== "string") return null;
+    return { at: parsed.at, snapshotId: parsed.snapshotId ?? null };
+  } catch {
+    return null;
+  }
+}