diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 28022ba..87429ab 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -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; } diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 59d85d8..7e439ee 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -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) => { diff --git a/frontend-modern/src/components/Dashboard/InvestigateProblemsButton.tsx b/frontend-modern/src/components/Dashboard/InvestigateProblemsButton.tsx index 84a5fc4..c57088c 100644 --- a/frontend-modern/src/components/Dashboard/InvestigateProblemsButton.tsx +++ b/frontend-modern/src/components/Dashboard/InvestigateProblemsButton.tsx @@ -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 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 diff --git a/frontend-modern/src/stores/alertsActivation.ts b/frontend-modern/src/stores/alertsActivation.ts index e20349d..889e156 100644 --- a/frontend-modern/src/stores/alertsActivation.ts +++ b/frontend-modern/src/stores/alertsActivation.ts @@ -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, diff --git a/frontend-modern/src/types/alerts.ts b/frontend-modern/src/types/alerts.ts index cdaec15..eba2e75 100644 --- a/frontend-modern/src/types/alerts.ts +++ b/frontend-modern/src/types/alerts.ts @@ -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'; diff --git a/frontend-modern/src/utils/__tests__/format.test.ts b/frontend-modern/src/utils/__tests__/format.test.ts index 1d7d887..e222757 100644 --- a/frontend-modern/src/utils/__tests__/format.test.ts +++ b/frontend-modern/src/utils/__tests__/format.test.ts @@ -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 + }); + }); }); diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index d57b5b2..45e4f1b 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -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'; diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index bcba8a2..423c564 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -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 diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 00aa7b7..58ac9f1 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -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)