feat: add backup status indicator to guest table

Shows shield icon next to guest name when backup is stale or missing:
- Yellow shield: backup 24-72 hours old
- Red shield: backup older than 72 hours
- Gray shield: no backup found

Fresh backups (<24h) show no indicator to keep the UI clean.
Templates are excluded from backup status display.
This commit is contained in:
rcourtman 2025-11-26 21:30:41 +00:00
parent f64f050c91
commit 91c72a274c
3 changed files with 138 additions and 1 deletions

View file

@ -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<BackupStatus, { color: string; bgColor: string; icon: 'check' | 'warning' | 'x' }> = {
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 (
<Show when={shouldShow()}>
<span
class={`flex-shrink-0 ${config().color}`}
title={tooltipText()}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
{/* Shield shape */}
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
{/* Inner icon based on status */}
<Show when={config().icon === 'warning'}>
<path d="M12 8v4M12 16h.01" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</Show>
<Show when={config().icon === 'x'}>
<path d="M10 10l4 4M14 10l-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</Show>
</svg>
</span>
</Show>
);
}
// Column configuration using the priority system
interface ColumnDef {
id: string;
@ -483,6 +534,7 @@ export function GuestRow(props: GuestRowProps) {
</svg>
</a>
</Show>
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
</div>
}
>

View file

@ -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),
};
}

View file

@ -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