From 155aa631035ab62a1aed5ca052b7eb7c57b431e4 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sun, 31 May 2026 22:44:22 +0200 Subject: [PATCH] fix(backup-dashboard): show last SUCCESSFUL backup + last attempt separately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard widget used `lastBackup.created_at` for the "Last successful backup: X ago" text — but lastBackup is the most recent row of any status. So a crashed restore (status=running, never updated) or a recent failure showed up labeled as the last successful backup. Same "silent failure not surfaced" class the restore wizard had. Backend now returns: lastSuccessfulBackup — most recent backup_runs with status='completed' zombieRuns — running rows older than 30 min (likely crashed mid-flight) lastBackup — unchanged (most recent any status) Frontend renders: - "Last successful backup: X ago" — always from lastSuccessfulBackup - "Last attempt: Y ago · failed/running" — when lastBackup differs from lastSuccessful. failed shows the first line of error_message in red; running stays neutral. - Zombie callout — "N backup(s) running >30min — may have crashed" in amber, so admin sees stuck rows at a glance. - Health score downgrades from "excellent" to "warning" if the latest attempt failed, even when older successes keep the age fresh — surfaces regressions without erasing the green history. --- backend/src/services/backupService.js | 23 ++++++ .../src/components/admin/BackupDashboard.tsx | 78 ++++++++++++++++--- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index eb9f4b71..aeeaf3d7 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -1237,11 +1237,34 @@ async function getBackupStatus(limit = 10) { const lastRunWithManifest = lastRun ? { ...lastRun, manifestValid } : null; + // Separate "most recent attempt" from "most recent SUCCESS" so the + // dashboard widget can distinguish: + // - last attempt failed → red, "Last attempt failed at X" + // - last attempt running → blue spinner, "In progress since X" + // - never succeeded → critical, "No successful backup yet" + // - last attempt succeeded → green tick, "Last backup X ago" + // Previously the widget showed the most-recent row with a generic + // green tick regardless of status, so a crashed run from 5 minutes + // ago looked identical to a successful one. Same "silent failure + // not surfaced" class Stage A was designed to fight. + const lastSuccessful = runs.find(r => r.status === 'completed') || null; + // Detect zombie running rows (started >30min ago, never updated) + // — these are processes that died without writing a completed_at. + // Surface them so the admin can tell at a glance vs a live run. + const ZOMBIE_THRESHOLD_MS = 30 * 60 * 1000; + const zombieRuns = runs.filter(r => + r.status === 'running' + && r.started_at + && (Date.now() - new Date(r.started_at).getTime()) > ZOMBIE_THRESHOLD_MS + ); + return { isRunning, isHealthy: Boolean(lastRun && lastRun.status === 'completed'), lastRun: lastRunWithManifest, lastBackup: lastRunWithManifest, // Alias for frontend compatibility + lastSuccessfulBackup: lastSuccessful, // NEW — see comment above + zombieRuns, // NEW — running >30min, likely crashed recentRuns: runs, recentBackups: runs, // Alias for frontend compatibility totalBackups: runs.filter(r => r.status === 'completed').length, diff --git a/frontend/src/components/admin/BackupDashboard.tsx b/frontend/src/components/admin/BackupDashboard.tsx index 5acf64bf..2bf608c3 100644 --- a/frontend/src/components/admin/BackupDashboard.tsx +++ b/frontend/src/components/admin/BackupDashboard.tsx @@ -38,11 +38,15 @@ interface BackupRecord { backup_type: string; created_at: string; duration_seconds: number; + started_at?: string; + error_message?: string; statistics?: BackupStatistics; } interface BackupStatus { - lastBackup?: BackupRecord; + lastBackup?: BackupRecord; // most recent attempt, any status + lastSuccessfulBackup?: BackupRecord; // most recent completed run + zombieRuns?: BackupRecord[]; // running > 30min, likely crashed totalBackups?: number; recentBackups?: BackupRecord[]; } @@ -105,18 +109,40 @@ const healthColors: Record = { export const BackupDashboard: React.FC = ({ status, config, onRunBackup, isBackupRunning }) => { const { t } = useTranslation(); - const lastBackup = status?.lastBackup; - const statistics = lastBackup?.statistics ?? {}; + const lastBackup = status?.lastBackup; // any status + const lastSuccessfulBackup = status?.lastSuccessfulBackup; // status='completed' only + const zombieRuns = status?.zombieRuns ?? []; + const statistics = lastSuccessfulBackup?.statistics ?? lastBackup?.statistics ?? {}; const isConfigured = config && config.backup_destination_type; const isEnabled = config?.backup_enabled; + // Use the most recent SUCCESSFUL backup as the "age" reference for + // health, so a transient failure doesn't immediately drop the score + // — but call out failed/running/zombie attempts explicitly so the + // admin sees them at a glance. const getHealthScore = (): { score: number; status: HealthStatus; message: string } => { - if (!lastBackup) return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.noBackups') }; + if (!lastSuccessfulBackup) { + // No successful backup ever recorded. + if (lastBackup?.status === 'failed') { + return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.lastBackupFailed') }; + } + return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.noBackups') }; + } - const hoursSinceBackup = (Date.now() - new Date(lastBackup.created_at).getTime()) / (1000 * 60 * 60); + const hoursSinceBackup = (Date.now() - new Date(lastSuccessfulBackup.created_at).getTime()) / (1000 * 60 * 60); - if (lastBackup.status === 'failed') { - return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.lastBackupFailed') }; + // A successful backup exists. Bias the score on its age, but if + // the MOST RECENT attempt failed, downgrade the message so the + // admin sees the regression even though older backups are fine. + const latestAttemptFailed = lastBackup && lastBackup.id !== lastSuccessfulBackup.id + && lastBackup.status === 'failed'; + + if (latestAttemptFailed) { + return { + score: 50, + status: 'warning', + message: t('backup.dashboard.healthMessages.lastBackupFailed'), + }; } if (hoursSinceBackup < 24) { @@ -190,9 +216,43 @@ export const BackupDashboard: React.FC = ({ status, config

{health.message}

- {lastBackup && ( + {/* Show the last successful backup explicitly — previously + this read `lastBackup.created_at` which silently rendered + a failed/running row as if it were the last success. */} + {lastSuccessfulBackup && (

- Last successful backup: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })} + {t('backup.dashboard.lastSuccessful', 'Last successful backup')}: {formatDistanceToNow(new Date(lastSuccessfulBackup.created_at), { addSuffix: true })} +

+ )} + {/* If the most recent attempt is NOT the last successful + run, surface it separately so the admin sees the + divergence (latest attempt failed or running). */} + {lastBackup && lastBackup.id !== lastSuccessfulBackup?.id && ( +

+ {t('backup.dashboard.lastAttempt', 'Last attempt')}: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })} + {' · '} + {t(`backup.dashboard.status.${lastBackup.status}`, lastBackup.status)} + {lastBackup.status === 'failed' && lastBackup.error_message && ( + + {lastBackup.error_message.split('\n')[0].slice(0, 200)} + + )} +

+ )} + {/* Zombie warning — running >30min, almost certainly crashed. + Admin needs to know they may be looking at a hung row + that won't ever flip to completed. */} + {zombieRuns.length > 0 && ( +

+ {t('backup.dashboard.zombieRuns', + '{{count}} backup(s) running >30min — may have crashed without completing', + { count: zombieRuns.length })}

)}