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;
// 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,
@@ -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<HealthStatus, string> = {
export const BackupDashboard: React.FC<BackupDashboardProps> = ({ 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<BackupDashboardProps> = ({ status, config
<div className="flex-1">
<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">
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>
)}