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 { GuestDrawer } from './GuestDrawer';
|
||||||
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
|
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
|
||||||
import type { VM, Container } from '@/types/api';
|
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 { TagBadges } from './TagBadges';
|
||||||
|
|
||||||
import { StatusDot } from '@/components/shared/StatusDot';
|
import { StatusDot } from '@/components/shared/StatusDot';
|
||||||
|
|
@ -49,6 +49,57 @@ const isVM = (guest: Guest): guest is VM => {
|
||||||
return guest.type === 'qemu';
|
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
|
// Column configuration using the priority system
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -483,6 +534,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</Show>
|
</Show>
|
||||||
|
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,14 @@ export function formatRelativeTime(timestamp: number): string {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diffMs = now - timestamp;
|
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
|
// Handle invalid or future timestamps
|
||||||
if (isNaN(diffMs) || !isFinite(diffMs)) return '';
|
if (isNaN(diffMs) || !isFinite(diffMs)) return '';
|
||||||
if (diffMs < 0) return '0s ago'; // Future timestamp, treat as current
|
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`;
|
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,
|
OSName: osName,
|
||||||
OSVersion: osVersion,
|
OSVersion: osVersion,
|
||||||
NetworkInterfaces: networkIfaces,
|
NetworkInterfaces: networkIfaces,
|
||||||
|
LastBackup: generateLastBackupTime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if status != "running" {
|
if status != "running" {
|
||||||
|
|
@ -1914,6 +1915,7 @@ func generateContainer(nodeName string, instance string, vmid int, config MockCo
|
||||||
Uptime: uptime,
|
Uptime: uptime,
|
||||||
ID: ctID,
|
ID: ctID,
|
||||||
Tags: generateTags(),
|
Tags: generateTags(),
|
||||||
|
LastBackup: generateLastBackupTime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if status != "running" {
|
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))
|
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
|
// generateTags generates random tags for a guest
|
||||||
func generateTags() []string {
|
func generateTags() []string {
|
||||||
// 30% chance of no tags
|
// 30% chance of no tags
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue