diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 58e803d..cd879e0 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -1,7 +1,7 @@ import { GuestDrawer } from './GuestDrawer'; import { createMemo, createSignal, createEffect, Show, For } from 'solid-js'; import type { VM, Container } from '@/types/api'; -import { formatBytes, formatUptime, formatSpeed } from '@/utils/format'; +import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus } from '@/utils/format'; import { TagBadges } from './TagBadges'; import { StatusDot } from '@/components/shared/StatusDot'; @@ -49,6 +49,57 @@ const isVM = (guest: Guest): guest is VM => { return guest.type === 'qemu'; }; +// Backup status indicator colors and icons +const BACKUP_STATUS_CONFIG: Record = { + fresh: { color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-100 dark:bg-green-900/40', icon: 'check' }, + stale: { color: 'text-yellow-600 dark:text-yellow-400', bgColor: 'bg-yellow-100 dark:bg-yellow-900/40', icon: 'warning' }, + critical: { color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/40', icon: 'x' }, + never: { color: 'text-gray-400 dark:text-gray-500', bgColor: 'bg-gray-100 dark:bg-gray-800', icon: 'x' }, +}; + +function BackupIndicator(props: { lastBackup: string | number | null | undefined; isTemplate: boolean }) { + // Don't show for templates + if (props.isTemplate) return null; + + const backupInfo = createMemo(() => getBackupInfo(props.lastBackup)); + const config = createMemo(() => BACKUP_STATUS_CONFIG[backupInfo().status]); + + // Only show when there's a problem (stale, critical, or never) + const shouldShow = createMemo(() => { + const status = backupInfo().status; + return status === 'stale' || status === 'critical' || status === 'never'; + }); + + const tooltipText = createMemo(() => { + const info = backupInfo(); + if (info.status === 'never') { + return 'No backup found'; + } + return `Last backup: ${info.ageFormatted}`; + }); + + return ( + + + + {/* Shield shape */} + + {/* Inner icon based on status */} + + + + + + + + + + ); +} + // Column configuration using the priority system interface ColumnDef { id: string; @@ -483,6 +534,7 @@ export function GuestRow(props: GuestRowProps) { + } > diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 5dd2e9c..adf0b37 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -83,6 +83,14 @@ export function formatRelativeTime(timestamp: number): string { const now = Date.now(); const diffMs = now - timestamp; + return formatTimeDiff(diffMs); +} + +/** + * Formats a time difference in milliseconds to a human-readable string. + * Internal helper used by formatRelativeTime and formatBackupAge. + */ +function formatTimeDiff(diffMs: number): string { // Handle invalid or future timestamps if (isNaN(diffMs) || !isFinite(diffMs)) return ''; if (diffMs < 0) return '0s ago'; // Future timestamp, treat as current @@ -108,3 +116,54 @@ export function formatRelativeTime(timestamp: number): string { return diffYears === 1 ? '1 year ago' : `${diffYears} years ago`; } } + +export type BackupStatus = 'fresh' | 'stale' | 'critical' | 'never'; + +export interface BackupInfo { + status: BackupStatus; + ageMs: number | null; + 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) + +/** + * Analyzes backup freshness for a guest. + * @param lastBackup - ISO timestamp string or Unix timestamp (ms) + * @returns BackupInfo with status and formatted age + */ +export function getBackupInfo(lastBackup: string | number | null | undefined): BackupInfo { + if (!lastBackup) { + return { status: 'never', ageMs: null, ageFormatted: 'Never' }; + } + + let timestamp: number; + if (typeof lastBackup === 'string') { + timestamp = new Date(lastBackup).getTime(); + } else { + timestamp = lastBackup; + } + + if (isNaN(timestamp) || timestamp <= 0) { + return { status: 'never', ageMs: null, ageFormatted: 'Never' }; + } + + const now = Date.now(); + const ageMs = now - timestamp; + + let status: BackupStatus; + if (ageMs <= BACKUP_FRESH_THRESHOLD_MS) { + status = 'fresh'; + } else if (ageMs <= BACKUP_STALE_THRESHOLD_MS) { + status = 'stale'; + } else { + status = 'critical'; + } + + return { + status, + ageMs, + ageFormatted: formatTimeDiff(ageMs), + }; +} diff --git a/internal/mock/generator.go b/internal/mock/generator.go index 4eaebb9..0f82f93 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -931,6 +931,7 @@ func generateVM(nodeName string, instance string, vmid int, config MockConfig) m OSName: osName, OSVersion: osVersion, NetworkInterfaces: networkIfaces, + LastBackup: generateLastBackupTime(), } if status != "running" { @@ -1914,6 +1915,7 @@ func generateContainer(nodeName string, instance string, vmid int, config MockCo Uptime: uptime, ID: ctID, Tags: generateTags(), + LastBackup: generateLastBackupTime(), } if status != "running" { @@ -1939,6 +1941,30 @@ func generateGuestName(prefix string) string { return fmt.Sprintf("%s-%s-%d", prefix, appNames[rand.Intn(len(appNames))], rand.Intn(100)) } +// generateLastBackupTime generates a realistic last backup timestamp. +// Distribution: 60% within 24h (fresh), 20% within 72h (stale), 10% older (critical), 10% never backed up +func generateLastBackupTime() time.Time { + r := rand.Float64() + now := time.Now() + + if r < 0.10 { + // 10% never backed up - return zero time + return time.Time{} + } else if r < 0.20 { + // 10% critical - backup 4-30 days ago + hoursAgo := 96 + rand.Intn(624) // 4-30 days in hours + return now.Add(-time.Duration(hoursAgo) * time.Hour) + } else if r < 0.40 { + // 20% stale - backup 24-72 hours ago + hoursAgo := 24 + rand.Intn(48) + return now.Add(-time.Duration(hoursAgo) * time.Hour) + } else { + // 60% fresh - backup within last 24 hours + hoursAgo := rand.Intn(24) + return now.Add(-time.Duration(hoursAgo) * time.Hour) + } +} + // generateTags generates random tags for a guest func generateTags() []string { // 30% chance of no tags