feat: configurable backup freshness thresholds for dashboard indicator

Adds FreshHours and StaleHours settings to control when the dashboard
backup indicator shows green (fresh), amber (stale), or red (critical).

- Backend: Added FreshHours/StaleHours to BackupAlertConfig (default 24/72 hours)
- Frontend: getBackupInfo() now accepts optional thresholds parameter
- Dashboard/GuestRow components use thresholds from alert config
- Settings saved/loaded with alert configuration

Closes #839
This commit is contained in:
rcourtman 2025-12-16 16:36:08 +00:00
parent cb960a04e3
commit a9c0a4d682
10 changed files with 110 additions and 11 deletions

View file

@ -607,7 +607,7 @@ export function Dashboard(props: DashboardProps) {
if (isDegraded) return true;
// Check for backup issues
const backupInfo = getBackupInfo(g.lastBackup);
const backupInfo = getBackupInfo(g.lastBackup, alertsActivation.getBackupThresholds());
if (backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never') {
return true;
}
@ -664,7 +664,7 @@ export function Dashboard(props: DashboardProps) {
guests = guests.filter((g) => {
// Skip templates - they don't need backups
if (g.template) return false;
const backupInfo = getBackupInfo(g.lastBackup);
const backupInfo = getBackupInfo(g.lastBackup, alertsActivation.getBackupThresholds());
// Show guests that need backup: stale, critical, or never backed up
return backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never';
});
@ -683,7 +683,7 @@ export function Dashboard(props: DashboardProps) {
if (isDegraded) return true;
// Check for backup issues
const backupInfo = getBackupInfo(g.lastBackup);
const backupInfo = getBackupInfo(g.lastBackup, alertsActivation.getBackupThresholds());
if (backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never') {
return true;
}

View file

@ -18,6 +18,7 @@ import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { aiChatStore } from '@/stores/aiChat';
import { useAlertsActivation } from '@/stores/alertsActivation';
type Guest = VM | Container;
@ -60,7 +61,8 @@ function BackupIndicator(props: { lastBackup: string | number | null | undefined
// Don't show for templates
if (props.isTemplate) return null;
const backupInfo = createMemo(() => getBackupInfo(props.lastBackup));
const alertsActivation = useAlertsActivation();
const backupInfo = createMemo(() => getBackupInfo(props.lastBackup, alertsActivation.getBackupThresholds()));
const config = createMemo(() => BACKUP_STATUS_CONFIG[backupInfo().status]);
// Only show when there's a problem (stale, critical, or never)
@ -319,7 +321,8 @@ function BackupStatusCell(props: { lastBackup: string | number | null | undefine
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
const info = createMemo(() => getBackupInfo(props.lastBackup));
const alertsActivation = useAlertsActivation();
const info = createMemo(() => getBackupInfo(props.lastBackup, alertsActivation.getBackupThresholds()));
const config = createMemo(() => BACKUP_STATUS_CONFIG[info().status]);
const handleMouseEnter = (e: MouseEvent) => {

View file

@ -3,6 +3,7 @@ import { aiChatStore } from '@/stores/aiChat';
import type { VM, Container } from '@/types/api';
import { getBackupInfo } from '@/utils/format';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
import { useAlertsActivation } from '@/stores/alertsActivation';
interface ProblemGuest {
guest: VM | Container;
@ -37,7 +38,8 @@ export const InvestigateProblemsButton: Component<InvestigateProblemsButtonProps
// Check for backup issues
if (!guest.template) {
const backupInfo = getBackupInfo(guest.lastBackup);
const alertsActivation = useAlertsActivation();
const backupInfo = getBackupInfo(guest.lastBackup, alertsActivation.getBackupThresholds());
if (backupInfo.status === 'critical') {
issues.push('Backup: Critical (very overdue)');
} else if (backupInfo.status === 'stale') {

View file

@ -977,10 +977,14 @@ export function Alerts() {
const warningDays =
safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning;
const criticalDays = Math.max(safeCritical, warningDays);
const freshHours = config.backupDefaults.freshHours ?? FACTORY_BACKUP_DEFAULTS.freshHours;
const staleHours = config.backupDefaults.staleHours ?? FACTORY_BACKUP_DEFAULTS.staleHours;
setBackupDefaults({
enabled,
warningDays,
criticalDays,
freshHours,
staleHours,
});
} else {
setBackupDefaults({ ...FACTORY_BACKUP_DEFAULTS });
@ -1315,6 +1319,8 @@ export function Alerts() {
enabled: false,
warningDays: 7,
criticalDays: 14,
freshHours: 24,
staleHours: 72,
};
// Threshold states - using trigger values for display
@ -1655,6 +1661,8 @@ export function Alerts() {
enabled: backupConfig.enabled,
warningDays: normalizedBackupWarning,
criticalDays: finalBackupCritical,
freshHours: backupConfig.freshHours ?? 24,
staleHours: backupConfig.staleHours ?? 72,
},
pmgDefaults: pmgThresholds(),
// Use rawOverridesConfig which is already properly formatted with disabled flags

View file

@ -118,6 +118,15 @@ const isPastObservationWindow = (): boolean => {
return Date.now() > expiryTime;
};
// Get backup indicator thresholds from config
const getBackupThresholds = (): { freshHours: number; staleHours: number } => {
const cfg = config();
return {
freshHours: cfg?.backupDefaults?.freshHours ?? 24,
staleHours: cfg?.backupDefaults?.staleHours ?? 72,
};
};
// Export the store
export const useAlertsActivation = () => ({
// Signals
@ -129,6 +138,7 @@ export const useAlertsActivation = () => ({
// Computed
isPastObservationWindow,
getBackupThresholds,
// Actions
refreshConfig,

View file

@ -103,6 +103,9 @@ export interface BackupAlertConfig {
enabled: boolean;
warningDays: number;
criticalDays: number;
// Dashboard indicator thresholds (separate from alert thresholds)
freshHours?: number; // Backups newer than this show as green (default: 24)
staleHours?: number; // Backups older than freshHours but newer than this show as amber (default: 72)
}
export type ActivationState = 'pending_review' | 'active' | 'snoozed';

View file

@ -312,4 +312,43 @@ describe('getBackupInfo', () => {
expect(result.status).toBe('critical');
});
});
describe('custom thresholds', () => {
it('uses custom freshHours threshold', () => {
const now = Date.now();
// 6 hours ago - would be fresh with default 24h threshold
const sixHoursAgo = now - 6 * 60 * 60 * 1000;
// With default thresholds (24h fresh, 72h stale)
const resultDefault = getBackupInfo(sixHoursAgo);
expect(resultDefault.status).toBe('fresh');
// With custom threshold of 4 hours for fresh
const resultCustom = getBackupInfo(sixHoursAgo, { freshHours: 4, staleHours: 12 });
expect(resultCustom.status).toBe('stale'); // 6 hours > 4 hours fresh threshold
});
it('uses custom staleHours threshold', () => {
const now = Date.now();
// 2 days ago - would be stale with default 72h threshold
const twoDaysAgo = now - 48 * 60 * 60 * 1000;
// With default thresholds
const resultDefault = getBackupInfo(twoDaysAgo);
expect(resultDefault.status).toBe('stale');
// With custom threshold of 24h stale (same as fresh)
const resultCustom = getBackupInfo(twoDaysAgo, { freshHours: 12, staleHours: 24 });
expect(resultCustom.status).toBe('critical'); // 48 hours > 24 hours stale threshold
});
it('handles partial custom thresholds', () => {
const now = Date.now();
const thirtyHoursAgo = now - 30 * 60 * 60 * 1000;
// Only provide freshHours, staleHours uses default (72h)
const result = getBackupInfo(thirtyHoursAgo, { freshHours: 12 });
expect(result.status).toBe('stale'); // 30h > 12h fresh, but < 72h default stale
});
});
});

View file

@ -121,15 +121,25 @@ export interface BackupInfo {
ageFormatted: string;
}
const BACKUP_FRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
const BACKUP_STALE_THRESHOLD_MS = 72 * 60 * 60 * 1000; // 72 hours (3 days)
// Default thresholds (used when no config is provided)
const DEFAULT_FRESH_HOURS = 24;
const DEFAULT_STALE_HOURS = 72;
export interface BackupThresholds {
freshHours?: number;
staleHours?: number;
}
/**
* Analyzes backup freshness for a guest.
* @param lastBackup - ISO timestamp string or Unix timestamp (ms)
* @param thresholds - Optional thresholds for fresh/stale determination (in hours)
* @returns BackupInfo with status and formatted age
*/
export function getBackupInfo(lastBackup: string | number | null | undefined): BackupInfo {
export function getBackupInfo(
lastBackup: string | number | null | undefined,
thresholds?: BackupThresholds
): BackupInfo {
if (!lastBackup) {
return { status: 'never', ageMs: null, ageFormatted: 'Never' };
}
@ -148,10 +158,16 @@ export function getBackupInfo(lastBackup: string | number | null | undefined): B
const now = Date.now();
const ageMs = now - timestamp;
// Use provided thresholds or fall back to defaults
const freshHours = thresholds?.freshHours ?? DEFAULT_FRESH_HOURS;
const staleHours = thresholds?.staleHours ?? DEFAULT_STALE_HOURS;
const freshThresholdMs = freshHours * 60 * 60 * 1000;
const staleThresholdMs = staleHours * 60 * 60 * 1000;
let status: BackupStatus;
if (ageMs <= BACKUP_FRESH_THRESHOLD_MS) {
if (ageMs <= freshThresholdMs) {
status = 'fresh';
} else if (ageMs <= BACKUP_STALE_THRESHOLD_MS) {
} else if (ageMs <= staleThresholdMs) {
status = 'stale';
} else {
status = 'critical';

View file

@ -351,6 +351,9 @@ type BackupAlertConfig struct {
Enabled bool `json:"enabled"`
WarningDays int `json:"warningDays"`
CriticalDays int `json:"criticalDays"`
// Indicator thresholds for the dashboard (separate from alert thresholds)
FreshHours int `json:"freshHours"` // Backups newer than this show as green (default: 24)
StaleHours int `json:"staleHours"` // Backups older than FreshHours but newer than this show as amber (default: 72)
}
// GuestLookup describes a guest identity used for snapshot/backup evaluations.
@ -624,6 +627,8 @@ func NewManager() *Manager {
Enabled: false,
WarningDays: 7,
CriticalDays: 14,
FreshHours: 24,
StaleHours: 72,
},
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
MinimumDelta: 2.0, // 2% minimum change

View file

@ -380,6 +380,8 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
Enabled: false,
WarningDays: 7,
CriticalDays: 14,
FreshHours: 24,
StaleHours: 72,
},
Overrides: make(map[string]alerts.ThresholdConfig),
}, nil
@ -485,6 +487,17 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
if config.BackupDefaults.CriticalDays > 0 && config.BackupDefaults.WarningDays > config.BackupDefaults.CriticalDays {
config.BackupDefaults.WarningDays = config.BackupDefaults.CriticalDays
}
// Default indicator thresholds for dashboard (separate from alert thresholds)
if config.BackupDefaults.FreshHours <= 0 {
config.BackupDefaults.FreshHours = 24
}
if config.BackupDefaults.StaleHours <= 0 {
config.BackupDefaults.StaleHours = 72
}
// Ensure stale threshold is at least as large as fresh threshold
if config.BackupDefaults.StaleHours < config.BackupDefaults.FreshHours {
config.BackupDefaults.StaleHours = config.BackupDefaults.FreshHours
}
config.MetricTimeThresholds = alerts.NormalizeMetricTimeThresholds(config.MetricTimeThresholds)
config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)