mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Expose native storage retention for VM and CT backups
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"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<StorageRetention | null>(null)
|
||||
const [fields, setFields] = useState<Record<string, string>>({})
|
||||
const [keepAll, setKeepAll] = 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 || !storage.prune_backups)
|
||||
setSaveError("")
|
||||
setMessage("")
|
||||
setEditing(storage)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing || busy) return
|
||||
const prune = buildRetention(fields, keepAll)
|
||||
if (!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 <Card className="border-border bg-card/50">
|
||||
<CardHeader className="pb-3 flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><HardDrive className="h-4 w-4" />{t("backup.storageRetention.title")}</CardTitle>
|
||||
<Button variant="ghost" size="sm" disabled={isValidating} onClick={() => { void mutate().catch(() => {}) }}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isValidating ? "animate-spin" : ""}`} />{t("backup.storageRetention.refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.description")}</p>
|
||||
{message && <p role="status" className="text-xs text-emerald-500">{message}</p>}
|
||||
{error ? <p role="alert" className="text-xs text-destructive">{t("backup.storageRetention.loadFailed")}</p> : !data ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> : !data.storages.length ?
|
||||
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.empty")}</p> :
|
||||
data.storages.map(storage => <div key={storage.id} className="flex flex-wrap gap-2 items-center border rounded-lg p-3">
|
||||
<span className="font-medium text-sm">{storage.id}</span><Badge variant="outline">{storage.type}</Badge>
|
||||
<span className="text-xs font-mono text-muted-foreground break-all">{storage.prune_backups || t("backup.storageRetention.default")}</span>
|
||||
<Button variant="outline" size="sm" className="ml-auto gap-1" onClick={() => edit(storage)} disabled={!storage.digest || busy}>
|
||||
<Pencil className="h-3 w-3" />{t("backup.storageRetention.edit")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
<Dialog open={!!editing} onOpenChange={open => { if (!open && !busy) setEditing(null) }}>
|
||||
<DialogContent className="bg-accent [&_input]:bg-background">
|
||||
<DialogHeader><DialogTitle>{t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })}</DialogTitle>
|
||||
<DialogDescription>{t("backup.storageRetention.effect")}</DialogDescription></DialogHeader>
|
||||
<Label className="flex items-center gap-2"><Checkbox checked={keepAll} disabled={busy} onCheckedChange={checked => {
|
||||
setKeepAll(checked === true)
|
||||
if (checked) setFields({})
|
||||
}} />{t("backup.storageRetention.keepAll")}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{RETENTION.map(key => <div key={key} className="space-y-1">
|
||||
<Label htmlFor={`storage-retention-${key}`}>{t(`backup.retention.${key}`)}</Label>
|
||||
<Input id={`storage-retention-${key}`} type="number" min={1} max={99999} step={1} disabled={keepAll || busy}
|
||||
value={fields[key] ?? ""} onChange={event => setFields(previous => ({ ...previous, [key]: event.target.value }))} />
|
||||
</div>)}
|
||||
</div>
|
||||
{saveError && <p role="alert" className="text-xs text-destructive break-words">{saveError}</p>}
|
||||
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>{t("backup.storageRetention.cancel")}</Button>
|
||||
<Button disabled={busy} onClick={save}>{busy && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}{t("backup.storageRetention.save")}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useState } from "react"
|
||||
import { DatabaseBackup } from "lucide-react"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||
import { BackupStorageRetention } from "./backup-storage-retention"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type BackupView = "host" | "guests"
|
||||
@@ -51,7 +52,7 @@ export function BackupTab() {
|
||||
<div className={view === "host" ? "" : "hidden"}>
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { RETENTION, parseRetention, buildRetention } from "../lib/backup-retention"
|
||||
|
||||
interface VmBackupJob {
|
||||
id: string
|
||||
@@ -70,24 +71,6 @@ const EMPTY_FORM: FormState = {
|
||||
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<string, string> = {}
|
||||
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<string, string>, 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)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Native Proxmox retention properties, shared by storage and job editors.
|
||||
export const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
export function parseRetention(prune: string) {
|
||||
const fields: Record<string, string> = {}
|
||||
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 }
|
||||
}
|
||||
|
||||
export function buildRetention(fields: Record<string, string>, 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" : ""
|
||||
}
|
||||
@@ -3938,6 +3938,23 @@
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"storageRetention": {
|
||||
"title": "Storage retention",
|
||||
"keepAll": "Keep all backups",
|
||||
"description": "Default backup retention stored in Proxmox. Backup jobs with an explicit policy override this default.",
|
||||
"refresh": "Refresh",
|
||||
"saved": "Storage retention saved. No pruning was run.",
|
||||
"choosePolicy": "Choose Keep all or at least one positive retention count.",
|
||||
"saveFailed": "Could not save storage retention. Refresh the list if the configuration changed.",
|
||||
"loadFailed": "Could not load storage retention.",
|
||||
"empty": "No backup storages found.",
|
||||
"default": "Proxmox default",
|
||||
"edit": "Edit",
|
||||
"editTitle": "Retention for {storage}",
|
||||
"effect": "Applies to future backup runs that inherit this storage policy. Saving does not delete existing backups. Protected backups remain protected.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save retention"
|
||||
},
|
||||
"actions": {
|
||||
"addAnother": "Add another",
|
||||
"addPath": "Add path",
|
||||
|
||||
@@ -16,6 +16,9 @@ Endpoints, all under /api/vm-backup-jobs:
|
||||
DELETE /<id> delete a job (its backups are kept)
|
||||
POST /<id>/toggle enable / disable
|
||||
POST /<id>/run run it now, as a Proxmox task on each node
|
||||
|
||||
Storage retention is read/edited under /api/vm-backup-storages. Saving a
|
||||
policy only changes the native storage configuration; it never runs pruning.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -229,6 +232,56 @@ def options():
|
||||
})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-storages", methods=["GET"])
|
||||
@require_auth
|
||||
def backup_storage_retention():
|
||||
ok, storages = _pvesh_json(["get", "/storage"])
|
||||
if not ok or not isinstance(storages, list):
|
||||
return _err(storages if not ok else "Invalid storage inventory", 502)
|
||||
return jsonify({"storages": [
|
||||
{"id": storage["storage"], "type": storage.get("type", ""),
|
||||
"prune_backups": _flat(storage.get("prune-backups") or ""),
|
||||
"digest": storage.get("digest", "")}
|
||||
for storage in storages
|
||||
if isinstance(storage, dict) and storage.get("storage")
|
||||
and "backup" in (storage.get("content") or "").split(",")
|
||||
]})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-storages/<storage_id>", methods=["PUT"])
|
||||
@require_admin_scope
|
||||
def update_backup_storage_retention(storage_id):
|
||||
if not STORAGE_RE.fullmatch(storage_id):
|
||||
return _err("Invalid storage")
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return _err("A retention policy and current configuration digest are required")
|
||||
digest, prune = data.get("digest"), data.get("prune_backups")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"[a-fA-F0-9]{40}", digest):
|
||||
return _err("Refresh the storage list before editing its retention")
|
||||
if not isinstance(prune, str) or not PRUNE_RE.fullmatch(prune):
|
||||
return _err("Invalid retention policy")
|
||||
pairs = [part.split("=", 1) for part in prune.split(",")]
|
||||
keys = [key for key, _ in pairs]
|
||||
if (len(keys) != len(set(keys)) or
|
||||
("keep-all" in keys and (len(keys) != 1 or pairs[0][1] != "1")) or
|
||||
not any(int(value) > 0 for _, value in pairs)):
|
||||
return _err("Choose keep-all=1 or one or more positive retention counts")
|
||||
# Check that this is still a backup storage, then let Proxmox check the
|
||||
# digest under its storage lock. Other storage settings are never passed.
|
||||
ok, storage = _pvesh_json(["get", f"/storage/{storage_id}"])
|
||||
if not ok:
|
||||
return _err(storage, 502)
|
||||
if not isinstance(storage, dict) or "backup" not in (storage.get("content") or "").split(","):
|
||||
return _err("This storage does not accept backups", 409)
|
||||
if storage.get("digest") != digest:
|
||||
return _err("Storage configuration changed; refresh before saving", 409)
|
||||
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, "--prune-backups", prune])
|
||||
if not ok:
|
||||
return _err(out, 409)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def create_job():
|
||||
|
||||
Reference in New Issue
Block a user