diff --git a/.github/scripts/tests/test_vm_backup_jobs_wiring.py b/.github/scripts/tests/test_vm_backup_jobs_wiring.py index a8ecf4f5..930c82e3 100644 --- a/.github/scripts/tests/test_vm_backup_jobs_wiring.py +++ b/.github/scripts/tests/test_vm_backup_jobs_wiring.py @@ -15,7 +15,7 @@ class VmBackupJobsWiring(TestCase): source = ROUTES.read_text() routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source) self.assertEqual(len(routes), source.count('@vm_backup_bp.route(')) - self.assertEqual(len(routes), 8) + self.assertEqual(len(routes), 10) for path, method, guard, name in routes: expected = 'require_auth' if method == 'GET' else 'require_admin_scope' self.assertEqual(guard, expected, f'{method} {path} ({name})') diff --git a/.github/workflows/test-apprise-services.yml b/.github/workflows/test-apprise-services.yml new file mode 100644 index 00000000..59df1fd0 --- /dev/null +++ b/.github/workflows/test-apprise-services.yml @@ -0,0 +1,50 @@ +name: Apprise Slack and Matrix + +on: + pull_request: + paths: + - '.github/workflows/test-apprise-services.yml' + - 'AppImage/scripts/notification_channels.py' + - 'AppImage/components/apprise-service-help.tsx' + - 'AppImage/components/notification-settings.tsx' + - 'tests/test_apprise_services.py' + - 'tests/test_apprise_service_help.cjs' + push: + branches: [main, develop] + paths: + - 'AppImage/scripts/notification_channels.py' + - 'AppImage/components/apprise-service-help.tsx' + - 'tests/test_apprise_services.py' + - 'tests/test_apprise_service_help.cjs' + workflow_dispatch: + +permissions: + contents: read + +jobs: + apprise-services: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + apprise: ['1.7.0', '2.0.1'] + 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 'apprise==${{ matrix.apprise }}' + npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund + - name: Test real provider plugins with mocked HTTP + run: python -B -m unittest discover -s tests -p test_apprise_services.py -v + - name: Render provider help and locale fallback + env: + NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules + run: node tests/test_apprise_service_help.cjs diff --git a/.github/workflows/test-backup-storage-retention.yml b/.github/workflows/test-backup-storage-retention.yml new file mode 100644 index 00000000..321a227e --- /dev/null +++ b/.github/workflows/test-backup-storage-retention.yml @@ -0,0 +1,50 @@ +name: Backup storage retention + +on: + pull_request: + paths: + - '.github/workflows/test-backup-storage-retention.yml' + - 'AppImage/scripts/flask_vm_backup_routes.py' + - 'AppImage/components/backup-storage-retention.tsx' + - 'AppImage/components/backup-tab.tsx' + - 'AppImage/components/vm-backup-jobs.tsx' + - 'AppImage/lib/backup-retention.ts' + - 'tests/test_vm_backup_jobs_api.py' + - 'tests/test_backup_storage_retention.cjs' + push: + branches: [main, develop] + paths: + - 'AppImage/scripts/flask_vm_backup_routes.py' + - 'AppImage/components/backup-storage-retention.tsx' + - 'AppImage/lib/backup-retention.ts' + - 'tests/test_vm_backup_jobs_api.py' + - 'tests/test_backup_storage_retention.cjs' + workflow_dispatch: + +permissions: + contents: read + +jobs: + storage-retention: + 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 job and storage configuration APIs + run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v + - name: Test shared retention properties + env: + NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules + run: node tests/test_backup_storage_retention.cjs diff --git a/.github/workflows/test-figurine.yml b/.github/workflows/test-figurine.yml new file mode 100644 index 00000000..b379ce4b --- /dev/null +++ b/.github/workflows/test-figurine.yml @@ -0,0 +1,73 @@ +name: Figurine installation + +on: + pull_request: + paths: + - '.github/workflows/test-figurine.yml' + - 'tests/test_figurine_installation.py' + - 'scripts/post_install/customizable_post_install.sh' + - 'AppImage/scripts/post_install_versions.py' + - 'AppImage/scripts/flask_proxmenux_routes.py' + push: + branches: [main, develop] + paths: + - '.github/workflows/test-figurine.yml' + - 'tests/test_figurine_installation.py' + - 'scripts/post_install/customizable_post_install.sh' + - 'AppImage/scripts/post_install_versions.py' + - 'AppImage/scripts/flask_proxmenux_routes.py' + workflow_dispatch: + +permissions: + contents: read + +jobs: + installer-and-release: + 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' + - name: Check shell syntax and isolated installer regressions + run: | + bash -n scripts/post_install/customizable_post_install.sh + python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v + - name: Install ARM user-mode emulation for release smoke tests + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends qemu-user + - name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries + env: + FIGURINE_VERSION: '2.1.1' + shell: bash + run: | + set -euo pipefail + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' EXIT + while read -r arch sha; do + archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz" + target="$scratch/$arch" + mkdir -p "$target" + curl --fail --location --retry 3 --output "$target/$archive" \ + "https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive" + printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check + tar -xf "$target/$archive" -C "$target" + runner=() + case "$arch" in + arm64) runner=(qemu-aarch64) ;; + arm) runner=(qemu-arm) ;; + esac + "${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version" + grep -F "figurine version v$FIGURINE_VERSION" "$target/version" + "${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner" + test -s "$target/banner" + printf 'PASS: %s version and login banner\n' "$arch" + done <<'ASSETS' + amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a + arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10 + arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d + ASSETS 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/apprise-service-help.tsx b/AppImage/components/apprise-service-help.tsx new file mode 100644 index 00000000..de9d132b --- /dev/null +++ b/AppImage/components/apprise-service-help.tsx @@ -0,0 +1,32 @@ +"use client" + +import { ExternalLink } from "lucide-react" +import { useT } from "../lib/i18n/provider" + +// These are examples, never defaults: opening help must not replace a saved +// destination or put placeholder credentials into the notification settings. +export function AppriseServiceHelp() { + const t = useT() + return
+ {t("settings.notifications.ui.appriseServiceHelp")} +
+
+

Slack

+

{t("settings.notifications.ui.appriseSlackSteps")}

+ https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C + + {t("settings.notifications.ui.appriseSlackDocs")} + +
+
+

Matrix

+

{t("settings.notifications.ui.appriseMatrixSteps")}

+ matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com + + {t("settings.notifications.ui.appriseMatrixDocs")} + +
+
+

{t("settings.notifications.ui.appriseServiceFinish")}

+
+} diff --git a/AppImage/components/backup-storage-retention.tsx b/AppImage/components/backup-storage-retention.tsx new file mode 100644 index 00000000..4d15c6a5 --- /dev/null +++ b/AppImage/components/backup-storage-retention.tsx @@ -0,0 +1,105 @@ +"use client" + +import { useState } from "react" +import useSWR from "swr" +import { HardDrive, Loader2, Pencil, RefreshCw } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" +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 { Input } from "./ui/input" +import { Label } from "./ui/label" +import { fetchApi } from "../lib/api-config" +import { buildRetention, parseRetention, RETENTION } from "../lib/backup-retention" +import { useT } from "../lib/i18n/provider" + +interface StorageRetention { id: string; type: string; prune_backups: string; digest: string } +const fetcher = (url: string) => fetchApi<{ storages: StorageRetention[] }>(url) + +export function BackupStorageRetention() { + const t = useT() + const { data, error, mutate, isValidating } = useSWR("/api/vm-backup-storages", fetcher) + const [editing, setEditing] = useState(null) + const [fields, setFields] = useState>({}) + const [keepAll, setKeepAll] = useState(false) + const [useDefault, setUseDefault] = useState(false) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState("") + const [saveError, setSaveError] = useState("") + + function edit(storage: StorageRetention) { + const policy = parseRetention(storage.prune_backups) + setFields(policy.fields) + setKeepAll(policy.keepAll) + setUseDefault(!storage.prune_backups) + setSaveError("") + setMessage("") + setEditing(storage) + } + + async function save() { + if (!editing || busy) return + const prune = useDefault ? "" : buildRetention(fields, keepAll) + if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy")) + setBusy(true) + setSaveError("") + try { + await fetchApi(`/api/vm-backup-storages/${encodeURIComponent(editing.id)}`, { + method: "PUT", body: JSON.stringify({ digest: editing.digest, prune_backups: prune }), + }) + setMessage(t("backup.storageRetention.saved")) + setEditing(null) + await mutate() + } catch (error) { + setSaveError(`${t("backup.storageRetention.saveFailed")} ${error instanceof Error ? error.message : ""}`) + } finally { setBusy(false) } + } + + return + + {t("backup.storageRetention.title")} + + + +

{t("backup.storageRetention.description")}

+ {message &&

{message}

} + {error ?

{t("backup.storageRetention.loadFailed")}

: !data ? + : !data.storages.length ? +

{t("backup.storageRetention.empty")}

: + data.storages.map(storage =>
+ {storage.id}{storage.type} + {storage.prune_backups || t("backup.storageRetention.default")} + +
)} +
+ { if (!open && !busy) setEditing(null) }}> + + {t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })} + {t("backup.storageRetention.effect")} + + +
+ {RETENTION.map(key =>
+ + setFields(previous => ({ ...previous, [key]: event.target.value }))} /> +
)} +
+ {saveError &&

{saveError}

} + + +
+
+
+} diff --git a/AppImage/components/backup-tab.tsx b/AppImage/components/backup-tab.tsx index e411f6f6..b97c6232 100644 --- a/AppImage/components/backup-tab.tsx +++ b/AppImage/components/backup-tab.tsx @@ -8,6 +8,7 @@ import { DatabaseBackup } from "lucide-react" import { HostBackup } from "./host-backup" import { VmBackupJobs } from "./vm-backup-jobs" import { PBSPanel } from "./pbs-panel" +import { BackupStorageRetention } from "./backup-storage-retention" import { useT } from "../lib/i18n/provider" type BackupView = "host" | "guests" | "pbs" @@ -52,7 +53,7 @@ export function BackupTab() {
- {view === "guests" && } + {view === "guests" && <>} {view === "pbs" && } ) 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 =>