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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user