fix(backup-dashboard): show last SUCCESSFUL backup + last attempt separately

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.
This commit is contained in:
Luca
2026-05-31 22:44:22 +02:00
parent 47ed6907d1
commit 155aa63103
2 changed files with 92 additions and 9 deletions
+23
View File
@@ -1237,11 +1237,34 @@ async function getBackupStatus(limit = 10) {
const lastRunWithManifest = lastRun ? { ...lastRun, manifestValid } : null; 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 { return {
isRunning, isRunning,
isHealthy: Boolean(lastRun && lastRun.status === 'completed'), isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
lastRun: lastRunWithManifest, lastRun: lastRunWithManifest,
lastBackup: lastRunWithManifest, // Alias for frontend compatibility lastBackup: lastRunWithManifest, // Alias for frontend compatibility
lastSuccessfulBackup: lastSuccessful, // NEW — see comment above
zombieRuns, // NEW — running >30min, likely crashed
recentRuns: runs, recentRuns: runs,
recentBackups: runs, // Alias for frontend compatibility recentBackups: runs, // Alias for frontend compatibility
totalBackups: runs.filter(r => r.status === 'completed').length, totalBackups: runs.filter(r => r.status === 'completed').length,
@@ -38,11 +38,15 @@ interface BackupRecord {
backup_type: string; backup_type: string;
created_at: string; created_at: string;
duration_seconds: number; duration_seconds: number;
started_at?: string;
error_message?: string;
statistics?: BackupStatistics; statistics?: BackupStatistics;
} }
interface BackupStatus { 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; totalBackups?: number;
recentBackups?: BackupRecord[]; recentBackups?: BackupRecord[];
} }
@@ -105,19 +109,41 @@ const healthColors: Record<HealthStatus, string> = {
export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config, onRunBackup, isBackupRunning }) => { export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config, onRunBackup, isBackupRunning }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const lastBackup = status?.lastBackup; const lastBackup = status?.lastBackup; // any status
const statistics = lastBackup?.statistics ?? {}; 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 isConfigured = config && config.backup_destination_type;
const isEnabled = config?.backup_enabled; 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 } => { 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.
const hoursSinceBackup = (Date.now() - new Date(lastBackup.created_at).getTime()) / (1000 * 60 * 60); if (lastBackup?.status === 'failed') {
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.lastBackupFailed') };
} }
return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.noBackups') };
}
const hoursSinceBackup = (Date.now() - new Date(lastSuccessfulBackup.created_at).getTime()) / (1000 * 60 * 60);
// 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) { if (hoursSinceBackup < 24) {
return { score: 100, status: 'excellent', message: t('backup.dashboard.healthMessages.upToDate') }; return { score: 100, status: 'excellent', message: t('backup.dashboard.healthMessages.upToDate') };
@@ -190,9 +216,43 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config
<div className="flex-1"> <div className="flex-1">
<p className="text-neutral-700 dark:text-neutral-300 font-medium">{health.message}</p> <p className="text-neutral-700 dark:text-neutral-300 font-medium">{health.message}</p>
{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 && (
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1"> <p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
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 })}
</p>
)}
{/* 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 && (
<p className={`text-sm mt-1 ${
lastBackup.status === 'failed'
? 'text-red-600 dark:text-red-400 font-medium'
: lastBackup.status === 'running'
? 'text-blue-600 dark:text-blue-400'
: 'text-neutral-500 dark:text-neutral-400'
}`}>
{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 && (
<span className="block text-xs text-red-600 dark:text-red-400 mt-0.5">
{lastBackup.error_message.split('\n')[0].slice(0, 200)}
</span>
)}
</p>
)}
{/* 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 && (
<p className="text-sm mt-1 text-amber-700 dark:text-amber-300 font-medium">
{t('backup.dashboard.zombieRuns',
'{{count}} backup(s) running >30min — may have crashed without completing',
{ count: zombieRuns.length })}
</p> </p>
)} )}