Let a storage return to the Proxmox default retention and match the job editor labels

- An empty policy removes the storage setting with --delete prune-backups, and a storage without a policy opens with that choice
- Retention fields use the Keep last, Keep daily... labels of the backup job editor
- A retention count of zero is rejected before calling Proxmox
- Spanish uses backups for the retention messages
This commit is contained in:
MacRimi
2026-10-16 23:34:21 +02:00
parent f7220826a6
commit f336eb65f0
5 changed files with 32 additions and 18 deletions
@@ -23,6 +23,7 @@ export function BackupStorageRetention() {
const [editing, setEditing] = useState<StorageRetention | null>(null) const [editing, setEditing] = useState<StorageRetention | null>(null)
const [fields, setFields] = useState<Record<string, string>>({}) const [fields, setFields] = useState<Record<string, string>>({})
const [keepAll, setKeepAll] = useState(false) const [keepAll, setKeepAll] = useState(false)
const [useDefault, setUseDefault] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("") const [message, setMessage] = useState("")
const [saveError, setSaveError] = useState("") const [saveError, setSaveError] = useState("")
@@ -30,7 +31,8 @@ export function BackupStorageRetention() {
function edit(storage: StorageRetention) { function edit(storage: StorageRetention) {
const policy = parseRetention(storage.prune_backups) const policy = parseRetention(storage.prune_backups)
setFields(policy.fields) setFields(policy.fields)
setKeepAll(policy.keepAll || !storage.prune_backups) setKeepAll(policy.keepAll)
setUseDefault(!storage.prune_backups)
setSaveError("") setSaveError("")
setMessage("") setMessage("")
setEditing(storage) setEditing(storage)
@@ -38,8 +40,8 @@ export function BackupStorageRetention() {
async function save() { async function save() {
if (!editing || busy) return if (!editing || busy) return
const prune = buildRetention(fields, keepAll) const prune = useDefault ? "" : buildRetention(fields, keepAll)
if (!prune) return setSaveError(t("backup.storageRetention.choosePolicy")) if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy"))
setBusy(true) setBusy(true)
setSaveError("") setSaveError("")
try { try {
@@ -79,14 +81,18 @@ export function BackupStorageRetention() {
<DialogContent className="bg-accent [&_input]:bg-background"> <DialogContent className="bg-accent [&_input]:bg-background">
<DialogHeader><DialogTitle>{t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })}</DialogTitle> <DialogHeader><DialogTitle>{t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })}</DialogTitle>
<DialogDescription>{t("backup.storageRetention.effect")}</DialogDescription></DialogHeader> <DialogDescription>{t("backup.storageRetention.effect")}</DialogDescription></DialogHeader>
<Label className="flex items-center gap-2"><Checkbox checked={keepAll} disabled={busy} onCheckedChange={checked => { <Label className="flex items-center gap-2"><Checkbox checked={useDefault} disabled={busy} onCheckedChange={checked => {
setUseDefault(checked === true)
if (checked) { setKeepAll(false); setFields({}) }
}} />{t("backup.storageRetention.useDefault")}</Label>
<Label className="flex items-center gap-2"><Checkbox checked={keepAll} disabled={useDefault || busy} onCheckedChange={checked => {
setKeepAll(checked === true) setKeepAll(checked === true)
if (checked) setFields({}) if (checked) setFields({})
}} />{t("backup.storageRetention.keepAll")}</Label> }} />{t("backup.storageRetention.keepAll")}</Label>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{RETENTION.map(key => <div key={key} className="space-y-1"> {RETENTION.map(key => <div key={key} className="space-y-1">
<Label htmlFor={`storage-retention-${key}`}>{t(`backup.retention.${key}`)}</Label> <Label htmlFor={`storage-retention-${key}`}>{t(`backup.retention.keep${key[0].toUpperCase()}${key.slice(1)}`)}</Label>
<Input id={`storage-retention-${key}`} type="number" min={1} max={99999} step={1} disabled={keepAll || busy} <Input id={`storage-retention-${key}`} type="number" min={1} max={99999} step={1} disabled={useDefault || keepAll || busy}
value={fields[key] ?? ""} onChange={event => setFields(previous => ({ ...previous, [key]: event.target.value }))} /> value={fields[key] ?? ""} onChange={event => setFields(previous => ({ ...previous, [key]: event.target.value }))} />
</div>)} </div>)}
</div> </div>
+1
View File
@@ -3948,6 +3948,7 @@
}, },
"backup": { "backup": {
"storageRetention": { "storageRetention": {
"useDefault": "Use the Proxmox default (no policy on this storage)",
"title": "Storage retention", "title": "Storage retention",
"keepAll": "Keep all backups", "keepAll": "Keep all backups",
"description": "Default backup retention stored in Proxmox. Backup jobs with an explicit policy override this default.", "description": "Default backup retention stored in Proxmox. Backup jobs with an explicit policy override this default.",
+8 -7
View File
@@ -4764,19 +4764,20 @@
"wednesday": "Miércoles" "wednesday": "Miércoles"
}, },
"storageRetention": { "storageRetention": {
"useDefault": "Usar el valor por defecto de Proxmox (sin política en este almacenamiento)",
"title": "Retención del almacenamiento", "title": "Retención del almacenamiento",
"keepAll": "Conservar todas las copias de seguridad", "keepAll": "Conservar todos los backups",
"description": "Retención de copias de seguridad predeterminada guardada en Proxmox. Las tareas de copia con una política explícita tienen prioridad sobre este valor.", "description": "Retención por defecto de los backups, guardada en Proxmox. Las tareas de backup con su propia política tienen prioridad sobre ella.",
"refresh": "Actualizar", "refresh": "Actualizar",
"saved": "Retención del almacenamiento guardada. No se ha ejecutado ninguna purga.", "saved": "Retención guardada. No se ha purgado ningún backup.",
"choosePolicy": "Elige Conservar todas o al menos un número de retención mayor que cero.", "choosePolicy": "Elige Conservar todos o al menos un número de retención mayor que cero.",
"saveFailed": "No se pudo guardar la retención del almacenamiento. Actualiza la lista si ha cambiado la configuración.", "saveFailed": "No se pudo guardar la retención del almacenamiento. Actualiza la lista si ha cambiado la configuración.",
"loadFailed": "No se pudo cargar la retención del almacenamiento.", "loadFailed": "No se pudo cargar la retención del almacenamiento.",
"empty": "No se han encontrado almacenamientos de copias de seguridad.", "empty": "No hay almacenamientos de backups.",
"default": "Valor predeterminado de Proxmox", "default": "Valor por defecto de Proxmox",
"edit": "Editar", "edit": "Editar",
"editTitle": "Retención de {storage}", "editTitle": "Retención de {storage}",
"effect": "Se aplica a las futuras copias de seguridad que hereden esta política de almacenamiento. Guardar no elimina las copias existentes. Las copias protegidas mantienen su protección.", "effect": "Se aplica a los próximos backups que hereden la política de este almacenamiento. Guardar no elimina los backups existentes, y los protegidos siguen protegidos.",
"cancel": "Cancelar", "cancel": "Cancelar",
"save": "Guardar retención" "save": "Guardar retención"
} }
+6 -4
View File
@@ -259,13 +259,14 @@ def update_backup_storage_retention(storage_id):
digest, prune = data.get("digest"), data.get("prune_backups") 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): 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") return _err("Refresh the storage list before editing its retention")
if not isinstance(prune, str) or not PRUNE_RE.fullmatch(prune): # An empty policy removes the storage setting, so the Proxmox default applies.
if not isinstance(prune, str) or (prune and not PRUNE_RE.fullmatch(prune)):
return _err("Invalid retention policy") return _err("Invalid retention policy")
pairs = [part.split("=", 1) for part in prune.split(",")] pairs = [part.split("=", 1) for part in prune.split(",")] if prune else []
keys = [key for key, _ in pairs] keys = [key for key, _ in pairs]
if (len(keys) != len(set(keys)) or if (len(keys) != len(set(keys)) or
("keep-all" in keys and (len(keys) != 1 or pairs[0][1] != "1")) or ("keep-all" in keys and (len(keys) != 1 or pairs[0][1] != "1")) or
not any(int(value) > 0 for _, value in pairs)): any(int(value) < 1 for _, value in pairs)):
return _err("Choose keep-all=1 or one or more positive retention counts") 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 # Check that this is still a backup storage, then let Proxmox check the
# digest under its storage lock. Other storage settings are never passed. # digest under its storage lock. Other storage settings are never passed.
@@ -276,7 +277,8 @@ def update_backup_storage_retention(storage_id):
return _err("This storage does not accept backups", 409) return _err("This storage does not accept backups", 409)
if storage.get("digest") != digest: if storage.get("digest") != digest:
return _err("Storage configuration changed; refresh before saving", 409) return _err("Storage configuration changed; refresh before saving", 409)
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, "--prune-backups", prune]) change = ["--prune-backups", prune] if prune else ["--delete", "prune-backups"]
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, *change])
if not ok: if not ok:
return _err(out, 409) return _err(out, 409)
return jsonify({"success": True}) return jsonify({"success": True})
+5 -1
View File
@@ -130,9 +130,13 @@ class VmBackupJobsApiTests(unittest.TestCase):
self.assertEqual(self.storage_policy("keep-all=1").status_code, 200) self.assertEqual(self.storage_policy("keep-all=1").status_code, 200)
self.assertEqual(self.changes()[0][-1], "keep-all=1") self.assertEqual(self.changes()[0][-1], "keep-all=1")
def test_an_empty_policy_returns_the_storage_to_the_proxmox_default(self):
self.assertEqual(self.storage_policy("").status_code, 200)
self.assertEqual(self.changes(), [["set", "/storage/local", "--digest", "a" * 40, "--delete", "prune-backups"]])
def test_invalid_storage_retention_is_rejected_before_commands(self): def test_invalid_storage_retention_is_rejected_before_commands(self):
invalid = [None, [], {}, {"digest": "a" * 40, "prune_backups": []}] invalid = [None, [], {}, {"digest": "a" * 40, "prune_backups": []}]
for prune in ("", "keep-last=-1", "keep-all=0", "keep-all=2", "keep-last=0", "keep-last=1,keep-last=2", for prune in ("keep-last=3,keep-daily=0", "keep-last=-1", "keep-all=0", "keep-all=2", "keep-last=0", "keep-last=1,keep-last=2",
"keep-all=1,keep-last=2", "keep-last=2 --delete path", "keep-last=1\n", "keep-other=1"): "keep-all=1,keep-last=2", "keep-last=2 --delete path", "keep-last=1\n", "keep-other=1"):
invalid.append({"digest": "a" * 40, "prune_backups": prune}) invalid.append({"digest": "a" * 40, "prune_backups": prune})
invalid += [{"digest": "invalid", "prune_backups": "keep-last=1"}] invalid += [{"digest": "invalid", "prune_backups": "keep-last=1"}]