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:
parent
cb960a04e3
commit
a9c0a4d682
10 changed files with 110 additions and 11 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue