diff --git a/.github/workflows/test-guest-backups.yml b/.github/workflows/test-guest-backups.yml new file mode 100644 index 00000000..60da0353 --- /dev/null +++ b/.github/workflows/test-guest-backups.yml @@ -0,0 +1,54 @@ +name: Guest backup archives + +on: + pull_request: + paths: + - '.github/workflows/test-guest-backups.yml' + - 'AppImage/scripts/guest_backup_archives.py' + - 'AppImage/scripts/flask_server.py' + - 'AppImage/scripts/jwt_middleware.py' + - 'AppImage/scripts/build_appimage.sh' + - 'AppImage/components/guest-backup-list.tsx' + - 'AppImage/components/virtual-machines.tsx' + - 'AppImage/lib/guest-backup-archives.ts' + - 'AppImage/messages/**' + - 'tests/test_guest_backup_archives.*' + push: + branches: [main, develop] + paths: + - 'AppImage/scripts/guest_backup_archives.py' + - 'AppImage/scripts/flask_server.py' + - 'AppImage/scripts/jwt_middleware.py' + - 'AppImage/components/guest-backup-list.tsx' + - 'AppImage/components/virtual-machines.tsx' + - 'AppImage/lib/guest-backup-archives.ts' + - 'tests/test_guest_backup_archives.*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + guest-backups: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + - name: Install fixture dependencies + run: | + python -m pip install 'Flask==3.1.2' + npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund + - name: Test native deletion, protection, authorization and cache invalidation + run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v + - name: Test frontend deletion and completion handling + env: + NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules + run: node tests/test_guest_backup_archives.cjs diff --git a/AppImage/components/guest-backup-list.tsx b/AppImage/components/guest-backup-list.tsx new file mode 100644 index 00000000..3f6878f7 --- /dev/null +++ b/AppImage/components/guest-backup-list.tsx @@ -0,0 +1,135 @@ +"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, storageColor }: { + vmid: number + backups: GuestBackup[] + loading: boolean + onRefresh: () => Promise + storageColor?: (storage: string) => { bg: string; text: string; border: string } +}) { + const t = useT() + const [selected, setSelected] = useState([]) + 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 + const storageClass = (storage: string) => { + const color = storageColor?.(storage) + return color ? `${color.bg} ${color.text} ${color.border}` : "" + } + + 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 <> +
+
+ {t("vmLxc.backups.available")} + {backups.length} +
+
+ + +
+
+ {message &&

{message}

} + {error &&

{error}

} + {loading ?
+ {t("vmLxc.backups.loading")} +
: backups.length === 0 ?
+ {t("vmLxc.backups.empty")} + {t("vmLxc.backups.emptyHint")} +
: <> + +
+ {backups.map(backup =>