Allow selecting and deleting guest backup archives in Monitor

This commit is contained in:
Jonatan
2026-10-16 20:23:09 +02:00
parent 3000c1cd66
commit d487f33700
10 changed files with 763 additions and 60 deletions
+129
View File
@@ -0,0 +1,129 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Archive, Clock, Loader2, LockKeyhole, RefreshCw, Trash2 } from "lucide-react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Checkbox } from "./ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
import { useT } from "@/lib/i18n/provider"
import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives"
export function GuestBackupList({ vmid, backups, loading, onRefresh }: {
vmid: number
backups: GuestBackup[]
loading: boolean
onRefresh: () => Promise<void>
}) {
const t = useT()
const [selected, setSelected] = useState<string[]>([])
const [confirm, setConfirm] = useState(false)
const [busy, setBusy] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState("")
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
// A fresh list can remove a selection or reveal a newly protected archive.
useEffect(() => {
setSelected(previous => previous.filter(volid => backups.some(backup => backup.volid === volid && !backup.protected)))
}, [backups])
const chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected)
const selectable = backups.filter(backup => !backup.protected)
const disabled = loading || busy || refreshing
async function refresh() {
setRefreshing(true)
setError("")
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
finally { if (mounted.current) setRefreshing(false) }
}
async function remove() {
if (!chosen.length || busy) return
setBusy(true)
setError("")
setMessage("")
try {
const results = await deleteGuestBackups(vmid, chosen)
if (!mounted.current) return
const deleted = results.filter(result => result.status === "deleted").length
const unconfirmed = results.length - deleted
setMessage(t("vmLxc.backups.deletedCount", { count: deleted }))
if (unconfirmed) setError(t("vmLxc.backups.unconfirmedCount", { count: unconfirmed }))
setSelected([])
} catch (error) {
if (mounted.current) setError(`${t("vmLxc.backups.deleteFailed")} ${error instanceof Error ? error.message : ""}`)
} finally {
// A timeout can hide a successful native task. Always bypass both caches.
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
if (mounted.current) { setBusy(false); setConfirm(false) }
}
}
return <>
<div className="flex items-center justify-between gap-2 mb-3 shrink-0 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
<Badge variant="secondary" className="text-xs h-5">{backups.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" disabled={disabled} onClick={refresh}>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{t("vmLxc.backups.refresh")}
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" disabled={disabled || !chosen.length} onClick={() => setConfirm(true)}>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
{t("vmLxc.backups.deleteSelected", { count: chosen.length })}
</Button>
</div>
</div>
{message && <p role="status" className="text-xs text-muted-foreground mb-2">{message}</p>}
{error && <p role="alert" className="text-xs text-destructive mb-2 break-words">{error}</p>}
{loading ? <div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" /><span className="text-sm">{t("vmLxc.backups.loading")}</span>
</div> : backups.length === 0 ? <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
<Archive className="h-12 w-12 mb-3 opacity-30" /><span className="text-sm">{t("vmLxc.backups.empty")}</span>
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
</div> : <>
<label className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
<Checkbox checked={selectable.length > 0 && chosen.length === Math.min(20, selectable.length)} disabled={disabled || !selectable.length}
onCheckedChange={checked => setSelected(checked ? selectable.slice(0, 20).map(backup => backup.volid) : [])} />
{t("vmLxc.backups.selectHint")}
</label>
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
{backups.map(backup => <label key={backup.volid} className="flex items-center gap-2 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<Checkbox aria-label={`${t("vmLxc.backups.selectBackup")} ${backup.date} ${backup.storage}`} checked={selected.includes(backup.volid)}
disabled={disabled || !!backup.protected || (chosen.length >= 20 && !selected.includes(backup.volid))}
onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} />
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm min-w-0 truncate" title={backup.volid}>{backup.date}</span>
{backup.protected && <Badge variant="secondary" className="gap-1 text-xs"><LockKeyhole className="h-3 w-3" />{t("vmLxc.backups.protected")}</Badge>}
<Badge variant="outline" className="text-xs ml-auto shrink-0">{backup.storage}</Badge>
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
</label>)}
</div>
</>}
<Dialog open={confirm} onOpenChange={value => { if (!busy) setConfirm(value) }}>
<DialogContent onEscapeKeyDown={event => { if (busy) event.preventDefault() }} onPointerDownOutside={event => { if (busy) event.preventDefault() }}>
<DialogHeader>
<DialogTitle>{t("vmLxc.backups.confirmTitle", { count: chosen.length })}</DialogTitle>
<DialogDescription>{t("vmLxc.backups.confirmDescription", { vmid })}</DialogDescription>
</DialogHeader>
<ul className="max-h-48 overflow-auto text-xs space-y-2 break-all">
{chosen.map(backup => <li key={backup.volid}>{backup.volid}</li>)}
</ul>
{busy && <p role="status" className="text-sm text-muted-foreground">{t("vmLxc.backups.deleting")}</p>}
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setConfirm(false)}>{t("vmLxc.backups.cancel")}</Button>
<Button variant="destructive" disabled={busy || !chosen.length} onClick={remove}>
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}{t("vmLxc.backups.confirmDelete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
+22 -59
View File
@@ -23,6 +23,8 @@ import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { GuestBackupList } from "./guest-backup-list"
import type { GuestBackup } from "../lib/guest-backup-archives"
import { OciConsoleLogPanel } from "./oci-console-log-panel"
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils"
@@ -382,16 +384,7 @@ interface BackupStorage {
avail_human?: string
}
interface VMBackup {
volid: string
storage: string
type: string
size: number
size_human: string
timestamp: number
date: string
notes?: string
}
type VMBackup = GuestBackup
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
// next to VMBackup since both are LXC-modal data structures.
@@ -1598,7 +1591,7 @@ export function VirtualMachines() {
}
}
const fetchVmBackups = async (vmid: number) => {
const fetchVmBackups = async (vmid: number, force = false) => {
// Stale-while-revalidate with a 6-hour freshness gate.
//
// 1. If we already have backups (bulk hydration or a previous
@@ -1615,15 +1608,16 @@ export function VirtualMachines() {
// 3. Loading spinner only on the true first load.
const GATE_MS = 6 * 60 * 60 * 1000
const seed = vmModalCacheRef.current.backups.get(vmid)
const isStale = !seed || (Date.now() - seed.fetchedAt) > GATE_MS
const isStale = force || !seed || (Date.now() - seed.fetchedAt) > GATE_MS
if (!seed) setLoadingBackups(true)
try {
const url = isStale
? `/api/vms/${vmid}/backups?fresh=1`
: `/api/vms/${vmid}/backups`
const response = await fetchApi<{ backups?: VMBackup[] }>(url)
const response = await fetchApi<{ backups?: VMBackup[]; error?: string }>(url)
if (response.error || !Array.isArray(response.backups)) throw new Error(response.error || "Backup inventory unavailable")
if (response.backups) {
setVmBackups(response.backups)
if (selectedVMRef.current?.vmid === vmid) setVmBackups(response.backups)
vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() })
}
} catch (error) {
@@ -1631,9 +1625,10 @@ export function VirtualMachines() {
// Only clear the visible list if we had nothing to show
// in the first place — a transient network hiccup must not
// wipe the stale-but-useful view the user is looking at.
if (!seed) setVmBackups([])
if (!seed && selectedVMRef.current?.vmid === vmid) setVmBackups([])
if (force) throw error
} finally {
if (!seed) setLoadingBackups(false)
if (!seed && selectedVMRef.current?.vmid === vmid) setLoadingBackups(false)
}
}
@@ -7436,7 +7431,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)}
{/* Backups Tab */}
{activeModalTab === "backups" && (
{activeModalTab === "backups" && selectedVM && (
/* Backups tab layout: the Card fills the whole tab
body so the backup list can scroll inside its
bordered card instead of leaving empty space
@@ -7471,48 +7466,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Divider */}
<div className="border-t border-border/50 mb-4 shrink-0" />
{/* Backup List */}
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
</div>
{loadingBackups ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">{t("vmLxc.backups.loading")}</span>
</div>
) : vmBackups.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
<Archive className="h-12 w-12 mb-3 opacity-30" />
<span className="text-sm">{t("vmLxc.backups.empty")}</span>
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
</div>
) : (
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
{vmBackups.map((backup, index) => (
<div
key={`backup-${backup.volid}-${index}`}
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-foreground">{backup.date}</span>
<Badge
variant="outline"
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
>
{backup.storage}
</Badge>
</div>
<Badge variant="outline" className="font-mono ml-2 flex-shrink-0">
{backup.size_human}
</Badge>
</div>
))}
</div>
)}
<GuestBackupList
key={selectedVM.vmid}
vmid={selectedVM.vmid}
backups={vmBackups}
loading={loadingBackups}
onRefresh={async () => {
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
await fetchVmBackups(selectedVM.vmid, true)
}}
/>
</CardContent>
</Card>
</div>