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:
parent
f64f050c91
commit
91c72a274c
3 changed files with 138 additions and 1 deletions
|
|
@ -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>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue