"use client" // The backup jobs of Proxmox for VMs and CTs (Datacenter > Backup). // Based on the contribution of MattiaC46 (PR #421). import { useState } from "react" import useSWR from "swr" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Checkbox } from "./ui/checkbox" import { ScrollArea } from "./ui/scroll-area" import { AlertTriangle, Archive, Calendar, CalendarClock, CheckCircle2, ChevronRight, DatabaseBackup, FileText, HardDrive, Layers, Loader2, Pencil, PlayCircle, Plus, Power, Server, Trash2, } from "lucide-react" import { fetchApi } from "../lib/api-config" import { cancelButtonClass } from "../lib/utils" import { useT } from "../lib/i18n/provider" interface VmBackupJob { id: string enabled?: number | boolean | string schedule?: string storage?: string mode?: string compress?: string | number all?: number | boolean | string vmid?: string node?: string "prune-backups"?: string "notes-template"?: string host_backups?: string[] } interface Guest { vmid: number; name: string; type: string; node?: string } interface Options { storages: { id: string; type: string }[]; guests: Guest[] } interface RunResult { tasks: { node: string; upid: string }[]; failures: { node: string; error: string }[] } interface FormState { id?: string name: string allGuests: boolean vmids: string[] storage: string originalStorage: string schedule: string mode: string compress: string retention: Record keepAll: boolean notes: string attached: string[] } const PRESETS = [ { key: "daily02", value: "02:00" }, { key: "daily03", value: "03:00" }, { key: "sunday02", value: "sun 02:00" }, { key: "every6h", value: "00/6:00" }, { key: "hourly", value: "hourly" }, ] const EMPTY_FORM: FormState = { name: "", allGuests: true, vmids: [], storage: "", originalStorage: "", schedule: "02:00", mode: "snapshot", compress: "zstd", retention: {}, keepAll: false, notes: "{{guestname}}", attached: [], } // The limits a job can keep, in the order Proxmox lists them. const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const const parseRetention = (prune: string) => { const fields: Record = {} let keepAll = false for (const part of prune.split(",")) { const [key, value] = part.trim().split("=") if (key === "keep-all") keepAll = value === "1" else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value)) } return { fields, keepAll } } const buildRetention = (fields: Record, keepAll: boolean) => { const parts = RETENTION.filter((key) => Number(fields[key]) > 0).map((key) => `keep-${key}=${Number(fields[key])}`) return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : "" } const isOn = (value: unknown) => value === 1 || value === true || value === "1" const jobEnabled = (job: VmBackupJob) => job.enabled === undefined || isOn(job.enabled) const guestIds = (job: VmBackupJob) => String(job.vmid ?? "").split(",").map((id) => id.trim()).filter(Boolean) const fetcher = async (url: string) => fetchApi(url) async function call(path: string, method: string, body?: unknown) { return fetchApi(path, { method, body: body ? JSON.stringify(body) : undefined }) } export function VmBackupJobs() { const t = useT() const { data, error, mutate } = useSWR<{ jobs: VmBackupJob[] }>("/api/vm-backup-jobs", fetcher, { refreshInterval: 30000 }) const { data: options } = useSWR("/api/vm-backup-jobs/options", fetcher, { refreshInterval: 60000 }) const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) const [form, setForm] = useState(null) const [formError, setFormError] = useState(null) const [deleting, setDeleting] = useState(null) const [running, setRunning] = useState(null) const [disabling, setDisabling] = useState(null) const [viewingId, setViewingId] = useState(null) const [busy, setBusy] = useState(null) const jobs = data?.jobs ?? [] const viewing = jobs.find((job) => job.id === viewingId) ?? null const guests = options?.guests ?? [] const storages = options?.storages ?? [] const describeGuests = (job: VmBackupJob) => { if (isOn(job.all)) return t("backup.vmJobs.allGuests") const ids = guestIds(job) if (ids.length === 0) return t("backup.vmJobs.noGuests") return ids.map((id) => { const guest = guests.find((item) => String(item.vmid) === id) return guest ? `${id} (${guest.name})` : id }).join(", ") } const openEdit = (job: VmBackupJob) => { setFormError(null) setForm({ id: job.id, name: job.id, allGuests: isOn(job.all), vmids: guestIds(job), storage: job.storage ?? "", originalStorage: job.storage ?? "", schedule: job.schedule ?? "02:00", mode: job.mode ?? "snapshot", compress: String(job.compress ?? "zstd"), retention: parseRetention(job["prune-backups"] ?? "").fields, keepAll: parseRetention(job["prune-backups"] ?? "").keepAll, notes: job["notes-template"] ?? "", attached: job.host_backups ?? [], }) } const save = async () => { if (!form) return if (!form.storage) return setFormError(t("backup.vmJobs.needStorage")) if (!form.allGuests && form.vmids.length === 0) return setFormError(t("backup.vmJobs.needGuests")) setBusy("save") setFormError(null) const payload: Record = { all: form.allGuests ? 1 : 0, vmid: form.allGuests ? "" : form.vmids.join(","), storage: form.storage, schedule: form.schedule.trim(), mode: form.mode, compress: form.compress, prune_backups: buildRetention(form.retention, form.keepAll), notes_template: form.notes.trim(), } if (!form.id && form.name.trim()) payload.id = form.name.trim() try { if (form.id) await call(`/api/vm-backup-jobs/${encodeURIComponent(form.id)}`, "PUT", payload) else await call("/api/vm-backup-jobs", "POST", payload) setMessage({ ok: true, text: form.id ? t("backup.vmJobs.updated") : t("backup.vmJobs.created") }) setForm(null) mutate() } catch (problem) { setFormError(problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.saveFailed")) } finally { setBusy(null) } } const toggle = async (job: VmBackupJob) => { setBusy(job.id) try { const result = await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}/toggle`, "POST") setMessage({ ok: true, text: t(result?.enabled ? "backup.vmJobs.toggledOn" : "backup.vmJobs.toggledOff", { id: job.id }) }) mutate() } catch (problem) { setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") }) } finally { setBusy(null) setDisabling(null) } } const runNow = async () => { if (!running) return const job = running setBusy(job.id) try { const result: RunResult = await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}/run`, "POST") let text = t("backup.vmJobs.started", { id: job.id, nodes: (result.tasks ?? []).map((task) => task.node).join(", ") }) if (result.failures?.length) { text += " " + t("backup.vmJobs.startedPartial", { nodes: result.failures.map((failure) => failure.node).join(", ") }) } setMessage({ ok: !result.failures?.length, text }) } catch (problem) { setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") }) } finally { setBusy(null) setRunning(null) } } const confirmDelete = async () => { if (!deleting) return const job = deleting setBusy(job.id) try { await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}`, "DELETE") setMessage({ ok: true, text: t("backup.vmJobs.deleted", { id: job.id }) }) setViewingId(null) mutate() } catch (problem) { setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") }) } finally { setBusy(null) setDeleting(null) } } const toggleGuest = (vmid: string) => setForm((current) => current && ({ ...current, vmids: current.vmids.includes(vmid) ? current.vmids.filter((id) => id !== vmid) : [...current.vmids, vmid], })) const preset = form && PRESETS.some((item) => item.value === form.schedule) ? form.schedule : "custom" const retentionOf = (job: VmBackupJob) => (job["prune-backups"] ?? "").split(",").map((part) => part.trim()).filter(Boolean) const compressionOf = (job: VmBackupJob) => String(job.compress ?? "zstd") === "0" ? t("backup.vmJobs.compressNone") : String(job.compress ?? "zstd") const retentionChips = (job: VmBackupJob) => { const parts = retentionOf(job) if (parts.length === 0) return {t("backup.vmJobs.storageDefault")} return parts.map((part) => { const [label, value] = part.split("=") const name = label.replace(/^keep-/, "") return ( {(RETENTION as readonly string[]).includes(name) ? t(`backup.retention.${name}`) : name} {value} ) }) } const badges = (job: VmBackupJob) => ( <> {!jobEnabled(job) && ( {t("status.disabled")} )} {(job.host_backups ?? []).length > 0 && ( {t("backup.vmJobs.hostBackup")} )} ) return ( <>
{t("backup.vmJobs.title")} {jobs.length}

{t("backup.vmJobs.subtitle")}

{message && !viewing && (
{message.text}
)} {error ? (
{t("backup.vmJobs.loadFailed")}
) : !data ? (
{t("backup.common.loading")}
) : jobs.length === 0 ? (
{t("backup.vmJobs.empty")}
) : (
{jobs.map((job) => ( ))}
)}
{/* Job detail */} { if (!open) { setViewingId(null); setMessage(null) } }}> {viewing?.id} {viewing && badges(viewing)} {t("backup.vmJobs.subtitle")} {message && (
{message.text}
)} {viewing && (

{t("backup.vmJobs.fieldGuests")}

{describeGuests(viewing)}

{t("backup.vmJobs.labelSchedule")}

{t("backup.vmJobs.labelSchedule")}
{viewing.schedule || "—"}
{t("backup.vmJobs.labelRetention")}
{retentionChips(viewing)}

{t("backup.vmJobs.labelStorage")}

{t("backup.vmJobs.labelStorage")}
{viewing.storage || "—"}
{t("backup.vmJobs.labelMode")}
{viewing.mode ?? "snapshot"}
{t("backup.vmJobs.fieldCompress")}
{compressionOf(viewing)}
{viewing["notes-template"] && (

{t("backup.vmJobs.fieldNotes")}

{viewing["notes-template"]}
)} {(viewing.host_backups ?? []).length > 0 && (
{t("backup.vmJobs.attachedHost", { ids: (viewing.host_backups ?? []).join(", ") })}
)}
)} {viewing && (
)}
{/* Create / edit: edit mode, the editable fields sunken */} !open && setForm(null)}> {form?.id ? t("backup.vmJobs.dialogEdit", { id: form.id }) : t("backup.vmJobs.dialogNew")} {t("backup.vmJobs.subtitle")} {form && (
{!form.id && (
setForm({ ...form, name: event.target.value })} />

{t("backup.vmJobs.fieldNameHint")}

)}
{!form.allGuests && (
{guests.map((guest) => ( ))}
)}
{storages.length === 0 ? (

{t("backup.vmJobs.noStorage")}

) : ( )} {form.attached.length > 0 && form.storage !== form.originalStorage && (

{t("backup.vmJobs.storageAttached", { ids: form.attached.join(", ") })}

)}
setForm({ ...form, schedule: event.target.value })} placeholder={t("backup.vmJobs.schedulePlaceholder")} />

{t("backup.retention.zeroDisables")}

{RETENTION.map((key) => (
setForm({ ...form, retention: { ...form.retention, [key]: event.target.value } })} className="font-mono mt-1" />
))}
setForm({ ...form, notes: event.target.value })} />
{formError && (
{formError}
)}
)}
{/* Run now */} !open && setRunning(null)}> {t("backup.vmJobs.runTitle", { id: running?.id ?? "" })} {t("backup.vmJobs.runBody")} {running?.mode === "stop" && (

{t("backup.vmJobs.runStop")}

)}
{/* Disable */} !open && setDisabling(null)}> {t("backup.vmJobs.disableTitle", { id: disabling?.id ?? "" })} {t("backup.vmJobs.disableBody")}
{/* Delete */} !open && setDeleting(null)}> {t("backup.vmJobs.deleteTitle", { id: deleting?.id ?? "" })} {t("backup.vmJobs.deleteBody")} {(deleting?.host_backups?.length ?? 0) > 0 && (

{t("backup.vmJobs.deleteAttached", { ids: (deleting?.host_backups ?? []).join(", ") })}

)}
) }