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;
|
if (isDegraded) return true;
|
||||||
|
|
||||||
// Check for backup issues
|
// 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') {
|
if (backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -664,7 +664,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
guests = guests.filter((g) => {
|
guests = guests.filter((g) => {
|
||||||
// Skip templates - they don't need backups
|
// Skip templates - they don't need backups
|
||||||
if (g.template) return false;
|
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
|
// Show guests that need backup: stale, critical, or never backed up
|
||||||
return backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never';
|
return backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never';
|
||||||
});
|
});
|
||||||
|
|
@ -683,7 +683,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
if (isDegraded) return true;
|
if (isDegraded) return true;
|
||||||
|
|
||||||
// Check for backup issues
|
// 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') {
|
if (backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||||
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
||||||
import { aiChatStore } from '@/stores/aiChat';
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
|
|
||||||
type Guest = VM | Container;
|
type Guest = VM | Container;
|
||||||
|
|
||||||
|
|
@ -60,7 +61,8 @@ function BackupIndicator(props: { lastBackup: string | number | null | undefined
|
||||||
// Don't show for templates
|
// Don't show for templates
|
||||||
if (props.isTemplate) return null;
|
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]);
|
const config = createMemo(() => BACKUP_STATUS_CONFIG[backupInfo().status]);
|
||||||
|
|
||||||
// Only show when there's a problem (stale, critical, or never)
|
// 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 [showTooltip, setShowTooltip] = createSignal(false);
|
||||||
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
|
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 config = createMemo(() => BACKUP_STATUS_CONFIG[info().status]);
|
||||||
|
|
||||||
const handleMouseEnter = (e: MouseEvent) => {
|
const handleMouseEnter = (e: MouseEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { aiChatStore } from '@/stores/aiChat';
|
||||||
import type { VM, Container } from '@/types/api';
|
import type { VM, Container } from '@/types/api';
|
||||||
import { getBackupInfo } from '@/utils/format';
|
import { getBackupInfo } from '@/utils/format';
|
||||||
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
|
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
|
||||||
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
|
|
||||||
interface ProblemGuest {
|
interface ProblemGuest {
|
||||||
guest: VM | Container;
|
guest: VM | Container;
|
||||||
|
|
@ -37,7 +38,8 @@ export const InvestigateProblemsButton: Component<InvestigateProblemsButtonProps
|
||||||
|
|
||||||
// Check for backup issues
|
// Check for backup issues
|
||||||
if (!guest.template) {
|
if (!guest.template) {
|
||||||
const backupInfo = getBackupInfo(guest.lastBackup);
|
const alertsActivation = useAlertsActivation();
|
||||||
|
const backupInfo = getBackupInfo(guest.lastBackup, alertsActivation.getBackupThresholds());
|
||||||
if (backupInfo.status === 'critical') {
|
if (backupInfo.status === 'critical') {
|
||||||
issues.push('Backup: Critical (very overdue)');
|
issues.push('Backup: Critical (very overdue)');
|
||||||
} else if (backupInfo.status === 'stale') {
|
} else if (backupInfo.status === 'stale') {
|
||||||
|
|
|
||||||
|
|
@ -977,10 +977,14 @@ export function Alerts() {
|
||||||
const warningDays =
|
const warningDays =
|
||||||
safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning;
|
safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning;
|
||||||
const criticalDays = Math.max(safeCritical, warningDays);
|
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({
|
setBackupDefaults({
|
||||||
enabled,
|
enabled,
|
||||||
warningDays,
|
warningDays,
|
||||||
criticalDays,
|
criticalDays,
|
||||||
|
freshHours,
|
||||||
|
staleHours,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setBackupDefaults({ ...FACTORY_BACKUP_DEFAULTS });
|
setBackupDefaults({ ...FACTORY_BACKUP_DEFAULTS });
|
||||||
|
|
@ -1315,6 +1319,8 @@ export function Alerts() {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
warningDays: 7,
|
warningDays: 7,
|
||||||
criticalDays: 14,
|
criticalDays: 14,
|
||||||
|
freshHours: 24,
|
||||||
|
staleHours: 72,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Threshold states - using trigger values for display
|
// Threshold states - using trigger values for display
|
||||||
|
|
@ -1655,6 +1661,8 @@ export function Alerts() {
|
||||||
enabled: backupConfig.enabled,
|
enabled: backupConfig.enabled,
|
||||||
warningDays: normalizedBackupWarning,
|
warningDays: normalizedBackupWarning,
|
||||||
criticalDays: finalBackupCritical,
|
criticalDays: finalBackupCritical,
|
||||||
|
freshHours: backupConfig.freshHours ?? 24,
|
||||||
|
staleHours: backupConfig.staleHours ?? 72,
|
||||||
},
|
},
|
||||||
pmgDefaults: pmgThresholds(),
|
pmgDefaults: pmgThresholds(),
|
||||||
// Use rawOverridesConfig which is already properly formatted with disabled flags
|
// Use rawOverridesConfig which is already properly formatted with disabled flags
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,15 @@ const isPastObservationWindow = (): boolean => {
|
||||||
return Date.now() > expiryTime;
|
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 the store
|
||||||
export const useAlertsActivation = () => ({
|
export const useAlertsActivation = () => ({
|
||||||
// Signals
|
// Signals
|
||||||
|
|
@ -129,6 +138,7 @@ export const useAlertsActivation = () => ({
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
isPastObservationWindow,
|
isPastObservationWindow,
|
||||||
|
getBackupThresholds,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
refreshConfig,
|
refreshConfig,
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,9 @@ export interface BackupAlertConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
warningDays: number;
|
warningDays: number;
|
||||||
criticalDays: 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';
|
export type ActivationState = 'pending_review' | 'active' | 'snoozed';
|
||||||
|
|
|
||||||
|
|
@ -312,4 +312,43 @@ describe('getBackupInfo', () => {
|
||||||
expect(result.status).toBe('critical');
|
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;
|
ageFormatted: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKUP_FRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
// Default thresholds (used when no config is provided)
|
||||||
const BACKUP_STALE_THRESHOLD_MS = 72 * 60 * 60 * 1000; // 72 hours (3 days)
|
const DEFAULT_FRESH_HOURS = 24;
|
||||||
|
const DEFAULT_STALE_HOURS = 72;
|
||||||
|
|
||||||
|
export interface BackupThresholds {
|
||||||
|
freshHours?: number;
|
||||||
|
staleHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analyzes backup freshness for a guest.
|
* Analyzes backup freshness for a guest.
|
||||||
* @param lastBackup - ISO timestamp string or Unix timestamp (ms)
|
* @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
|
* @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) {
|
if (!lastBackup) {
|
||||||
return { status: 'never', ageMs: null, ageFormatted: 'Never' };
|
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 now = Date.now();
|
||||||
const ageMs = now - timestamp;
|
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;
|
let status: BackupStatus;
|
||||||
if (ageMs <= BACKUP_FRESH_THRESHOLD_MS) {
|
if (ageMs <= freshThresholdMs) {
|
||||||
status = 'fresh';
|
status = 'fresh';
|
||||||
} else if (ageMs <= BACKUP_STALE_THRESHOLD_MS) {
|
} else if (ageMs <= staleThresholdMs) {
|
||||||
status = 'stale';
|
status = 'stale';
|
||||||
} else {
|
} else {
|
||||||
status = 'critical';
|
status = 'critical';
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,9 @@ type BackupAlertConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
WarningDays int `json:"warningDays"`
|
WarningDays int `json:"warningDays"`
|
||||||
CriticalDays int `json:"criticalDays"`
|
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.
|
// GuestLookup describes a guest identity used for snapshot/backup evaluations.
|
||||||
|
|
@ -624,6 +627,8 @@ func NewManager() *Manager {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WarningDays: 7,
|
WarningDays: 7,
|
||||||
CriticalDays: 14,
|
CriticalDays: 14,
|
||||||
|
FreshHours: 24,
|
||||||
|
StaleHours: 72,
|
||||||
},
|
},
|
||||||
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
|
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||||
MinimumDelta: 2.0, // 2% minimum change
|
MinimumDelta: 2.0, // 2% minimum change
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,8 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WarningDays: 7,
|
WarningDays: 7,
|
||||||
CriticalDays: 14,
|
CriticalDays: 14,
|
||||||
|
FreshHours: 24,
|
||||||
|
StaleHours: 72,
|
||||||
},
|
},
|
||||||
Overrides: make(map[string]alerts.ThresholdConfig),
|
Overrides: make(map[string]alerts.ThresholdConfig),
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -485,6 +487,17 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
|
||||||
if config.BackupDefaults.CriticalDays > 0 && config.BackupDefaults.WarningDays > config.BackupDefaults.CriticalDays {
|
if config.BackupDefaults.CriticalDays > 0 && config.BackupDefaults.WarningDays > config.BackupDefaults.CriticalDays {
|
||||||
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.MetricTimeThresholds = alerts.NormalizeMetricTimeThresholds(config.MetricTimeThresholds)
|
||||||
config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
|
config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue