mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Merge develop and retain guest retention beside PBS navigation
This commit is contained in:
@@ -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 <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
|
||||
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
|
||||
<div className="grid gap-3 pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h4 className="font-medium">Slack</h4>
|
||||
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
|
||||
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
|
||||
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||
{t("settings.notifications.ui.appriseSlackDocs")} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h4 className="font-medium">Matrix</h4>
|
||||
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
|
||||
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
|
||||
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||
{t("settings.notifications.ui.appriseMatrixDocs")} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
|
||||
</details>
|
||||
}
|
||||
@@ -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<StorageRetention | null>(null)
|
||||
const [fields, setFields] = useState<Record<string, string>>({})
|
||||
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 <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={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)
|
||||
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.keep${key[0].toUpperCase()}${key.slice(1)}`)}</Label>
|
||||
<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 }))} />
|
||||
</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>
|
||||
}
|
||||
@@ -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() {
|
||||
<div className={view === "host" ? "" : "hidden"}>
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
|
||||
{view === "pbs" && <PBSPanel />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<void>
|
||||
storageColor?: (storage: string) => { bg: string; text: string; border: string }
|
||||
}) {
|
||||
const t = useT()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
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 <>
|
||||
<div className="flex items-center justify-between gap-2 mb-3 shrink-0 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">{backups.length}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" disabled={disabled} onClick={refresh}>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{t("vmLxc.backups.refresh")}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" disabled={disabled || !chosen.length} onClick={() => setConfirm(true)}>
|
||||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||
{t("vmLxc.backups.deleteSelected", { count: chosen.length })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{message && <p role="status" className="text-xs text-muted-foreground mb-2">{message}</p>}
|
||||
{error && <p role="alert" className="text-xs text-destructive mb-2 break-words">{error}</p>}
|
||||
{loading ? <div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" /><span className="text-sm">{t("vmLxc.backups.loading")}</span>
|
||||
</div> : backups.length === 0 ? <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
|
||||
<Archive className="h-12 w-12 mb-3 opacity-30" /><span className="text-sm">{t("vmLxc.backups.empty")}</span>
|
||||
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
|
||||
</div> : <>
|
||||
<label className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
|
||||
<Checkbox checked={selectable.length > 0 && chosen.length === Math.min(20, selectable.length)} disabled={disabled || !selectable.length}
|
||||
onCheckedChange={checked => setSelected(checked ? selectable.slice(0, 20).map(backup => backup.volid) : [])} />
|
||||
{t("vmLxc.backups.selectHint")}
|
||||
</label>
|
||||
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
|
||||
{backups.map(backup => <label key={backup.volid} className="flex items-center gap-2 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<Checkbox aria-label={`${t("vmLxc.backups.selectBackup")} ${backup.date} ${backup.storage}`} checked={selected.includes(backup.volid)}
|
||||
disabled={disabled || !!backup.protected || (chosen.length >= 20 && !selected.includes(backup.volid))}
|
||||
onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} />
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm min-w-0 truncate" title={backup.volid}>{backup.date}</span>
|
||||
{backup.protected && <Badge variant="secondary" className="gap-1 text-xs"><LockKeyhole className="h-3 w-3" />{t("vmLxc.backups.protected")}</Badge>}
|
||||
<Badge variant="outline" className={`text-xs ml-auto shrink-0 ${storageClass(backup.storage)}`}>{backup.storage}</Badge>
|
||||
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
|
||||
</label>)}
|
||||
</div>
|
||||
</>}
|
||||
<Dialog open={confirm} onOpenChange={value => { if (!busy) setConfirm(value) }}>
|
||||
<DialogContent onEscapeKeyDown={event => { if (busy) event.preventDefault() }} onPointerDownOutside={event => { if (busy) event.preventDefault() }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vmLxc.backups.confirmTitle", { count: chosen.length })}</DialogTitle>
|
||||
<DialogDescription>{t("vmLxc.backups.confirmDescription", { vmid })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="max-h-48 overflow-auto text-xs space-y-2 break-all">
|
||||
{chosen.map(backup => <li key={backup.volid}>{backup.volid}</li>)}
|
||||
</ul>
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">{t("vmLxc.backups.deleting")}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setConfirm(false)}>{t("vmLxc.backups.cancel")}</Button>
|
||||
<Button variant="destructive" disabled={busy || !chosen.length} onClick={remove}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}{t("vmLxc.backups.confirmDelete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { AppriseServiceHelp } from "./apprise-service-help"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
@@ -2061,6 +2062,7 @@ export function NotificationSettings() {
|
||||
Enable toggle + Test button pattern as the other
|
||||
channels. */}
|
||||
<TabsContent value="apprise" className="space-y-3 pt-2">
|
||||
<AppriseServiceHelp />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { MetricsView } from "./metrics-dialog"
|
||||
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
|
||||
import { GuestBackupList } from "./guest-backup-list"
|
||||
import type { GuestBackup } from "../lib/guest-backup-archives"
|
||||
import { OciConsoleLogPanel } from "./oci-console-log-panel"
|
||||
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
@@ -399,16 +401,7 @@ interface BackupStorage {
|
||||
avail_human?: string
|
||||
}
|
||||
|
||||
interface VMBackup {
|
||||
volid: string
|
||||
storage: string
|
||||
type: string
|
||||
size: number
|
||||
size_human: string
|
||||
timestamp: number
|
||||
date: string
|
||||
notes?: string
|
||||
}
|
||||
type VMBackup = GuestBackup
|
||||
|
||||
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
|
||||
// next to VMBackup since both are LXC-modal data structures.
|
||||
@@ -1615,7 +1608,7 @@ export function VirtualMachines() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVmBackups = async (vmid: number) => {
|
||||
const fetchVmBackups = async (vmid: number, force = false) => {
|
||||
// Stale-while-revalidate with a 6-hour freshness gate.
|
||||
//
|
||||
// 1. If we already have backups (bulk hydration or a previous
|
||||
@@ -1632,15 +1625,16 @@ export function VirtualMachines() {
|
||||
// 3. Loading spinner only on the true first load.
|
||||
const GATE_MS = 6 * 60 * 60 * 1000
|
||||
const seed = vmModalCacheRef.current.backups.get(vmid)
|
||||
const isStale = !seed || (Date.now() - seed.fetchedAt) > GATE_MS
|
||||
const isStale = force || !seed || (Date.now() - seed.fetchedAt) > GATE_MS
|
||||
if (!seed) setLoadingBackups(true)
|
||||
try {
|
||||
const url = isStale
|
||||
? `/api/vms/${vmid}/backups?fresh=1`
|
||||
: `/api/vms/${vmid}/backups`
|
||||
const response = await fetchApi<{ backups?: VMBackup[] }>(url)
|
||||
const response = await fetchApi<{ backups?: VMBackup[]; error?: string }>(url)
|
||||
if (response.error || !Array.isArray(response.backups)) throw new Error(response.error || "Backup inventory unavailable")
|
||||
if (response.backups) {
|
||||
setVmBackups(response.backups)
|
||||
if (selectedVMRef.current?.vmid === vmid) setVmBackups(response.backups)
|
||||
vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() })
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1648,9 +1642,10 @@ export function VirtualMachines() {
|
||||
// Only clear the visible list if we had nothing to show
|
||||
// in the first place — a transient network hiccup must not
|
||||
// wipe the stale-but-useful view the user is looking at.
|
||||
if (!seed) setVmBackups([])
|
||||
if (!seed && selectedVMRef.current?.vmid === vmid) setVmBackups([])
|
||||
if (force) throw error
|
||||
} finally {
|
||||
if (!seed) setLoadingBackups(false)
|
||||
if (!seed && selectedVMRef.current?.vmid === vmid) setLoadingBackups(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7460,7 +7455,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
|
||||
{/* Backups Tab */}
|
||||
{activeModalTab === "backups" && (
|
||||
{activeModalTab === "backups" && selectedVM && (
|
||||
/* Backups tab layout: the Card fills the whole tab
|
||||
body so the backup list can scroll inside its
|
||||
bordered card instead of leaving empty space
|
||||
@@ -7495,48 +7490,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/50 mb-4 shrink-0" />
|
||||
|
||||
{/* Backup List */}
|
||||
<div className="flex items-center justify-between mb-3 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
|
||||
</div>
|
||||
|
||||
{loadingBackups ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm">{t("vmLxc.backups.loading")}</span>
|
||||
</div>
|
||||
) : vmBackups.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
|
||||
<Archive className="h-12 w-12 mb-3 opacity-30" />
|
||||
<span className="text-sm">{t("vmLxc.backups.empty")}</span>
|
||||
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
|
||||
{vmBackups.map((backup, index) => (
|
||||
<div
|
||||
key={`backup-${backup.volid}-${index}`}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-foreground">{backup.date}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
|
||||
>
|
||||
{backup.storage}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono ml-2 flex-shrink-0">
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<GuestBackupList
|
||||
key={selectedVM.vmid}
|
||||
vmid={selectedVM.vmid}
|
||||
backups={vmBackups}
|
||||
loading={loadingBackups}
|
||||
onRefresh={async () => {
|
||||
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
|
||||
await fetchVmBackups(selectedVM.vmid, true)
|
||||
}}
|
||||
storageColor={getStorageColor}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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" : ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fetchApi } from './api-config'
|
||||
|
||||
export interface GuestBackup {
|
||||
volid: string
|
||||
storage: string
|
||||
type: string
|
||||
size: number
|
||||
size_human: string
|
||||
timestamp: number
|
||||
date: string
|
||||
notes?: string
|
||||
protected?: boolean
|
||||
}
|
||||
|
||||
export interface BackupDeletionResult {
|
||||
volid: string
|
||||
status: 'deleted' | 'pending' | 'unconfirmed'
|
||||
task?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Exposed separately so callers can invalidate their inventory even after a
|
||||
// lost POST response. Never retry the destructive request automatically.
|
||||
export async function deleteGuestBackups(vmid: number, backups: GuestBackup[]): Promise<BackupDeletionResult[]> {
|
||||
const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirm: true, backups: backups.map(({ volid, storage }) => ({ volid, storage })) }),
|
||||
// Up to 20 archives, checked first and then deleted four at a time.
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
})
|
||||
if (!Array.isArray(response.results) || response.results.length !== backups.length ||
|
||||
response.results.some((result, index) => result.volid !== backups[index].volid ||
|
||||
!['deleted', 'pending', 'unconfirmed'].includes(result.status))) throw new Error('deletionUnconfirmed')
|
||||
|
||||
return Promise.all(response.results.map(async result => {
|
||||
if (result.status !== 'pending' || !result.task) return result
|
||||
const deadline = Date.now() + 120_000
|
||||
let failures = 0
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
try {
|
||||
const status = await fetchApi<BackupDeletionResult>(
|
||||
`/api/vms/${vmid}/backups/delete-status?task=${encodeURIComponent(result.task!)}`,
|
||||
{ signal: AbortSignal.timeout(10_000) },
|
||||
)
|
||||
if (status.volid !== result.volid || !['deleted', 'pending', 'unconfirmed'].includes(status.status)) break
|
||||
if (status.status !== 'pending') return status
|
||||
failures = 0
|
||||
} catch {
|
||||
if (++failures >= 3) break
|
||||
}
|
||||
}
|
||||
return { ...result, status: 'unconfirmed', error: 'deletionUnconfirmed' }
|
||||
}))
|
||||
}
|
||||
@@ -1110,7 +1110,21 @@
|
||||
"available": "Verfügbare Backups",
|
||||
"loading": "Backups werden geladen...",
|
||||
"empty": "Keine Backups gefunden",
|
||||
"emptyHint": "Erstellen Sie Ihr erstes Backup über die Schaltfläche oben"
|
||||
"emptyHint": "Erstellen Sie Ihr erstes Backup über die Schaltfläche oben",
|
||||
"refresh": "Aktualisieren",
|
||||
"refreshFailed": "Sicherungen konnten nicht aktualisiert werden. Aktualisieren Sie erneut, um ihren aktuellen Zustand zu bestätigen.",
|
||||
"deleteSelected": "Ausgewählte löschen ({count})",
|
||||
"deletedCount": "{count} Sicherung(en) gelöscht.",
|
||||
"unconfirmedCount": "Die Löschung von {count} Sicherung(en) wurde nicht bestätigt. Aktualisieren Sie die Liste und prüfen Sie die Proxmox-Aufgaben, bevor Sie es erneut versuchen.",
|
||||
"deleteFailed": "Die Löschung konnte nicht bestätigt werden. Aktualisieren Sie die Liste und prüfen Sie die Proxmox-Aufgaben, bevor Sie es erneut versuchen.",
|
||||
"selectHint": "Ungeschützte Sicherungen auswählen (bis zu 20 pro Durchgang)",
|
||||
"selectBackup": "Sicherung auswählen",
|
||||
"protected": "Geschützt",
|
||||
"confirmTitle": "{count} Sicherung(en) löschen?",
|
||||
"confirmDescription": "Diese Sicherungsarchive für VM/CT {vmid} werden dauerhaft gelöscht. Geschützte Sicherungen sind ausgeschlossen. Der Gast wird dadurch nicht gelöscht.",
|
||||
"deleting": "Warten, bis Proxmox die ausgewählten Archive gelöscht hat…",
|
||||
"cancel": "Abbrechen",
|
||||
"confirmDelete": "Sicherungen löschen"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Ausstehende Paketaktualisierungen",
|
||||
@@ -1304,7 +1318,7 @@
|
||||
"Samstag"
|
||||
],
|
||||
"runSkippedStatus": "Lauf übersprungen",
|
||||
"reasonStoppedContainer": "Container ist gestoppt;Das geplante Update wurde übersprungen.",
|
||||
"reasonStoppedContainer": "Container ist gestoppt; das geplante Update wurde übersprungen.",
|
||||
"reasonStoppedContainerHistorical": "Der Container wurde zum geplanten Aktualisierungszeitpunkt gestoppt."
|
||||
},
|
||||
"cronChip": {
|
||||
@@ -2111,7 +2125,13 @@
|
||||
"inactiveFooter": "Aktivieren Sie Benachrichtigungen, um System-, Gesundheits- und Sicherheitswarnungen über die konfigurierten Kanäle zu erhalten.",
|
||||
"notificationLanguage": "Benachrichtigungssprache",
|
||||
"selectNotificationLanguage": "Benachrichtigungssprache auswählen",
|
||||
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist."
|
||||
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist.",
|
||||
"appriseServiceHelp": "Slack und Matrix einrichten",
|
||||
"appriseSlackSteps": "Erstellen Sie in Ihrer Slack-App einen eingehenden Webhook für den Zielkanal und fügen Sie dessen vollständige HTTPS-URL in das Feld Apprise-URL ein. Eine Umwandlung ist nicht erforderlich.",
|
||||
"appriseSlackDocs": "Slack-URL-Formate und Einrichtung",
|
||||
"appriseMatrixSteps": "Verwenden Sie ein Benachrichtigungskonto, das in den Raum schreiben darf. Ersetzen Sie den Beispielbenutzer, das Passwort, den Homeserver und die vollständige Raum-ID; kodieren Sie Sonderzeichen in Zugangsdaten für URLs. Das Schema matrixs verwendet HTTPS. Informationen zur Unterstützung von Tokens, SSO und verschlüsselten Räumen in Ihrer installierten Apprise-Version finden Sie in der Dokumentation.",
|
||||
"appriseMatrixDocs": "Matrix-URL-Formate und Einrichtung",
|
||||
"appriseServiceFinish": "Aktivieren Sie Apprise, geben Sie Ihre URL ein, wählen Sie Benachrichtigungskategorien oder einzelne Ereignisse aus, speichern Sie und verwenden Sie anschließend Test senden. Diese Beispiele ändern Ihre gespeicherte URL nicht. Dieser Apprise-Kanal hat ein Ziel; verwenden Sie die Apprise-API für mehrere Dienste gleichzeitig."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Verbesserung",
|
||||
@@ -4756,6 +4776,24 @@
|
||||
"thursday": "Donnerstag",
|
||||
"tuesday": "Dienstag",
|
||||
"wednesday": "Mittwoch"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Aufbewahrung im Speicher",
|
||||
"keepAll": "Alle Sicherungen behalten",
|
||||
"description": "In Proxmox gespeicherte Standardaufbewahrung für Sicherungen. Sicherungsaufträge mit einer eigenen Richtlinie überschreiben diesen Standard.",
|
||||
"refresh": "Aktualisieren",
|
||||
"saved": "Aufbewahrungsrichtlinie des Speichers gespeichert. Es wurde keine Bereinigung ausgeführt.",
|
||||
"choosePolicy": "Wählen Sie Alle behalten oder mindestens eine Aufbewahrungsanzahl größer als null.",
|
||||
"saveFailed": "Aufbewahrungsrichtlinie des Speichers konnte nicht gespeichert werden. Aktualisieren Sie die Liste, falls die Konfiguration geändert wurde.",
|
||||
"loadFailed": "Aufbewahrungsrichtlinie des Speichers konnte nicht geladen werden.",
|
||||
"empty": "Keine Sicherungsspeicher gefunden.",
|
||||
"default": "Proxmox-Standard",
|
||||
"edit": "Bearbeiten",
|
||||
"editTitle": "Aufbewahrung für {storage}",
|
||||
"effect": "Gilt für zukünftige Sicherungsläufe, die diese Speicherrichtlinie übernehmen. Das Speichern löscht keine vorhandenen Sicherungen. Geschützte Sicherungen bleiben geschützt.",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Aufbewahrung speichern",
|
||||
"useDefault": "Proxmox-Standard verwenden (keine Richtlinie für diesen Speicher)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -1184,7 +1184,21 @@
|
||||
"available": "Available backups",
|
||||
"loading": "Loading backups...",
|
||||
"empty": "No backups found",
|
||||
"emptyHint": "Create your first backup using the button above"
|
||||
"emptyHint": "Create your first backup using the button above",
|
||||
"refresh": "Refresh",
|
||||
"refreshFailed": "Could not refresh backups. Refresh again to confirm their current state.",
|
||||
"deleteSelected": "Delete selected ({count})",
|
||||
"deletedCount": "{count} backup(s) deleted.",
|
||||
"unconfirmedCount": "Deletion of {count} backup(s) was not confirmed. Refresh the list and check Proxmox Tasks before trying again.",
|
||||
"deleteFailed": "Deletion could not be confirmed. Refresh the list and check Proxmox Tasks before trying again.",
|
||||
"selectHint": "Select unprotected backups (up to 20 per batch)",
|
||||
"selectBackup": "Select backup",
|
||||
"protected": "Protected",
|
||||
"confirmTitle": "Delete {count} backup(s)?",
|
||||
"confirmDescription": "These backup archives for VM/CT {vmid} will be permanently deleted. Protected backups are excluded. This does not delete the guest.",
|
||||
"deleting": "Waiting for Proxmox to finish deleting the selected archives…",
|
||||
"cancel": "Cancel",
|
||||
"confirmDelete": "Delete backups"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Pending package updates",
|
||||
@@ -2150,6 +2164,12 @@
|
||||
"pushoverCredentialsHint": "Create an application in Pushover and copy its API token and your user or group key.",
|
||||
"enableApprise": "Enable Apprise",
|
||||
"urlFormats": "+URL formats",
|
||||
"appriseServiceHelp": "Slack and Matrix setup",
|
||||
"appriseSlackSteps": "Create an Incoming Webhook in your Slack app for the target channel, then paste its complete HTTPS URL into Apprise URL. No conversion is needed.",
|
||||
"appriseSlackDocs": "Slack URL formats and setup",
|
||||
"appriseMatrixSteps": "Use a notification account that can post to the room. Replace the example user, password, homeserver and full room ID; URL-encode special characters in credentials. The matrixs scheme uses HTTPS. See the documentation for token, SSO and encrypted-room support in your installed Apprise version.",
|
||||
"appriseMatrixDocs": "Matrix URL formats and setup",
|
||||
"appriseServiceFinish": "Enable Apprise, enter your URL, choose the notification categories or individual events, save, then use Send Test. These examples do not change your saved URL. This Apprise channel has one destination; use Apprise API for several services at once.",
|
||||
"appriseUrl": "Apprise URL",
|
||||
"showUrl": "Show URL",
|
||||
"hideUrl": "Hide URL",
|
||||
@@ -3994,6 +4014,24 @@
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"storageRetention": {
|
||||
"useDefault": "Use the Proxmox default (no policy on this storage)",
|
||||
"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",
|
||||
|
||||
@@ -660,7 +660,7 @@
|
||||
"controllerBusyTime": "Minutos totales que el controlador pasó procesando comandos de E/S. Los valores altos indican una carga de trabajo pesada y sostenida.",
|
||||
"errorLogEntries": "Número de entradas en el registro de información de errores. A menudo incluye artefactos benignos de autoevaluación; verifique con Errores de Medios.",
|
||||
"warningTempTime": "Total de minutos transcurridos por encima del umbral de temperatura de advertencia. Provoca una limitación del rendimiento. El cero es ideal.",
|
||||
"criticalTempTime": "Minutos totales pasados por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
|
||||
"criticalTempTime": "Minutos totales pasados por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
|
||||
"warningCompositeTemperatureTime": "Minutos totales que la temperatura compuesta superó el umbral de advertencia.",
|
||||
"criticalCompositeTemperatureTime": "Minutos totales la temperatura compuesta superó el umbral crítico. Debe ser cero.",
|
||||
"thermalManagementT1TransCount": "Número de veces que la unidad entró en aceleración térmica ligera (T1). Indica problemas de enfriamiento.",
|
||||
@@ -1163,7 +1163,21 @@
|
||||
"available": "Backups disponibles",
|
||||
"loading": "Cargando backups...",
|
||||
"empty": "No se encontraron backups",
|
||||
"emptyHint": "Crea tu primer backup con el botón de arriba"
|
||||
"emptyHint": "Crea tu primer backup con el botón de arriba",
|
||||
"refresh": "Actualizar",
|
||||
"refreshFailed": "No se pudieron actualizar los backups. Vuelve a actualizar para confirmar su estado actual.",
|
||||
"deleteSelected": "Eliminar los seleccionados ({count})",
|
||||
"deletedCount": "{count} backup(s) eliminado(s).",
|
||||
"unconfirmedCount": "No se ha confirmado la eliminación de {count} backup(s). Actualiza la lista y comprueba las tareas de Proxmox antes de volver a intentarlo.",
|
||||
"deleteFailed": "No se pudo confirmar la eliminación. Actualiza la lista y comprueba las tareas de Proxmox antes de volver a intentarlo.",
|
||||
"selectHint": "Selecciona backups sin protección (hasta 20 por lote)",
|
||||
"selectBackup": "Seleccionar backup",
|
||||
"protected": "Protegido",
|
||||
"confirmTitle": "¿Eliminar {count} backup(s)?",
|
||||
"confirmDescription": "Los archivos de backup de la VM/CT {vmid} se eliminarán de forma permanente. Los backups protegidos quedan fuera. La VM o el contenedor no se eliminan.",
|
||||
"deleting": "Esperando a que Proxmox termine de eliminar los archivos seleccionados…",
|
||||
"cancel": "Cancelar",
|
||||
"confirmDelete": "Eliminar backups"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Actualizaciones de paquetes pendientes",
|
||||
@@ -1357,8 +1371,8 @@
|
||||
"sábado"
|
||||
],
|
||||
"runSkippedStatus": "ejecución omitida",
|
||||
"reasonStoppedContainer": "el contenedor está detenido;Se omitió la actualización programada.",
|
||||
"reasonStoppedContainerHistorical": "el contenedor se detuvo a la hora de actualización programada."
|
||||
"reasonStoppedContainer": "El contenedor está parado; se ha omitido la actualización programada.",
|
||||
"reasonStoppedContainerHistorical": "El contenedor estaba parado a la hora de la actualización programada."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron del host detectado",
|
||||
@@ -2164,7 +2178,13 @@
|
||||
"inactiveFooter": "Habilite notificaciones para recibir alertas del sistema, de salud y de seguridad a través de los canales configurados.",
|
||||
"notificationLanguage": "Idioma de notificación",
|
||||
"selectNotificationLanguage": "Seleccione el idioma de notificación",
|
||||
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada."
|
||||
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada.",
|
||||
"appriseServiceHelp": "Configuración de Slack y Matrix",
|
||||
"appriseSlackSteps": "Crea un webhook entrante en tu aplicación de Slack para el canal de destino y pega su URL HTTPS completa en URL de Apprise. No hace falta convertirla.",
|
||||
"appriseSlackDocs": "Formatos de URL y configuración de Slack",
|
||||
"appriseMatrixSteps": "Utiliza una cuenta de notificaciones que pueda publicar en la sala. Sustituye el usuario, la contraseña, el servidor de origen y el ID completo de la sala del ejemplo; codifica para URL los caracteres especiales de las credenciales. El esquema matrixs utiliza HTTPS. Consulta en la documentación la compatibilidad con tokens, SSO y salas cifradas de tu versión de Apprise.",
|
||||
"appriseMatrixDocs": "Formatos de URL y configuración de Matrix",
|
||||
"appriseServiceFinish": "Activa Apprise, introduce tu URL, elige las categorías de notificación o los eventos individuales, guarda y utiliza Enviar prueba. Estos ejemplos no cambian tu URL guardada. Este canal de Apprise tiene un único destino; utiliza la API de Apprise para varios servicios a la vez."
|
||||
},
|
||||
"ai": {
|
||||
"title": "mejora de la IA",
|
||||
@@ -3619,8 +3639,8 @@
|
||||
"short": {
|
||||
"hour": "Pasada 1 hora",
|
||||
"day": "Pasadas 24h",
|
||||
"week": "Pasados los 7 días",
|
||||
"month": "Pasados los 30 días",
|
||||
"week": "Pasados los 7 días",
|
||||
"month": "Pasados los 30 días",
|
||||
"year": "Último 1 año"
|
||||
},
|
||||
"last": {
|
||||
@@ -4809,6 +4829,24 @@
|
||||
"thursday": "Jueves",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles"
|
||||
},
|
||||
"storageRetention": {
|
||||
"useDefault": "Usar el valor por defecto de Proxmox (sin política en este almacenamiento)",
|
||||
"title": "Retención del almacenamiento",
|
||||
"keepAll": "Conservar todos los backups",
|
||||
"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",
|
||||
"saved": "Retención guardada. No se ha purgado ningún backup.",
|
||||
"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.",
|
||||
"loadFailed": "No se pudo cargar la retención del almacenamiento.",
|
||||
"empty": "No hay almacenamientos de backups.",
|
||||
"default": "Valor por defecto de Proxmox",
|
||||
"edit": "Editar",
|
||||
"editTitle": "Retención de {storage}",
|
||||
"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",
|
||||
"save": "Guardar retención"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -1110,7 +1110,21 @@
|
||||
"available": "Sauvegardes disponibles",
|
||||
"loading": "Chargement des sauvegardes...",
|
||||
"empty": "Aucune sauvegarde trouvée",
|
||||
"emptyHint": "Créez votre première sauvegarde en utilisant le bouton ci-dessus"
|
||||
"emptyHint": "Créez votre première sauvegarde en utilisant le bouton ci-dessus",
|
||||
"refresh": "Actualiser",
|
||||
"refreshFailed": "Impossible d'actualiser les sauvegardes. Actualisez à nouveau pour confirmer leur état actuel.",
|
||||
"deleteSelected": "Supprimer la sélection ({count})",
|
||||
"deletedCount": "{count} sauvegarde(s) supprimée(s).",
|
||||
"unconfirmedCount": "La suppression de {count} sauvegarde(s) n'a pas été confirmée. Actualisez la liste et vérifiez les tâches Proxmox avant de réessayer.",
|
||||
"deleteFailed": "La suppression n'a pas pu être confirmée. Actualisez la liste et vérifiez les tâches Proxmox avant de réessayer.",
|
||||
"selectHint": "Sélectionnez les sauvegardes non protégées (jusqu'à 20 par lot)",
|
||||
"selectBackup": "Sélectionner la sauvegarde",
|
||||
"protected": "Protégée",
|
||||
"confirmTitle": "Supprimer {count} sauvegarde(s) ?",
|
||||
"confirmDescription": "Ces archives de sauvegarde de la VM/CT {vmid} seront définitivement supprimées. Les sauvegardes protégées sont exclues. Cela ne supprime pas l'invité.",
|
||||
"deleting": "En attente de la fin de la suppression des archives sélectionnées par Proxmox…",
|
||||
"cancel": "Annuler",
|
||||
"confirmDelete": "Supprimer les sauvegardes"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Mises à jour des packages en attente",
|
||||
@@ -1304,8 +1318,8 @@
|
||||
"Samedi"
|
||||
],
|
||||
"runSkippedStatus": "exécution ignorée",
|
||||
"reasonStoppedContainer": "le conteneur est arrêté ;la mise à jour programmée a été ignorée.",
|
||||
"reasonStoppedContainerHistorical": "le conteneur a été arrêté à l'heure de mise à jour prévue."
|
||||
"reasonStoppedContainer": "Le conteneur est arrêté ; la mise à jour programmée a été ignorée.",
|
||||
"reasonStoppedContainerHistorical": "Le conteneur était arrêté à l'heure de la mise à jour programmée."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron hôte détecté",
|
||||
@@ -2111,7 +2125,13 @@
|
||||
"inactiveFooter": "Activez les notifications pour recevoir des alertes système, de santé et de sécurité via les canaux configurés.",
|
||||
"notificationLanguage": "Langue de notification",
|
||||
"selectNotificationLanguage": "Sélectionnez la langue de notification",
|
||||
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée."
|
||||
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée.",
|
||||
"appriseServiceHelp": "Configuration de Slack et Matrix",
|
||||
"appriseSlackSteps": "Créez un webhook entrant dans votre application Slack pour le canal cible, puis collez son URL HTTPS complète dans le champ URL Apprise. Aucune conversion n'est nécessaire.",
|
||||
"appriseSlackDocs": "Formats d'URL et configuration de Slack",
|
||||
"appriseMatrixSteps": "Utilisez un compte de notification autorisé à publier dans le salon. Remplacez l'utilisateur, le mot de passe, le serveur d'origine et l'identifiant complet du salon de l'exemple ; encodez les caractères spéciaux des identifiants pour les URL. Le schéma matrixs utilise HTTPS. Consultez la documentation pour la prise en charge des jetons, du SSO et des salons chiffrés dans votre version d'Apprise.",
|
||||
"appriseMatrixDocs": "Formats d'URL et configuration de Matrix",
|
||||
"appriseServiceFinish": "Activez Apprise, saisissez votre URL, choisissez les catégories de notification ou les événements individuels, enregistrez, puis utilisez Envoyer un test. Ces exemples ne modifient pas votre URL enregistrée. Ce canal Apprise possède une destination ; utilisez l'API Apprise pour plusieurs services à la fois."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Amélioration de l'IA",
|
||||
@@ -4756,6 +4776,24 @@
|
||||
"thursday": "Jeudi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Rétention du stockage",
|
||||
"keepAll": "Conserver toutes les sauvegardes",
|
||||
"description": "Rétention par défaut des sauvegardes enregistrée dans Proxmox. Les tâches de sauvegarde avec une politique explicite remplacent cette valeur par défaut.",
|
||||
"refresh": "Actualiser",
|
||||
"saved": "Rétention du stockage enregistrée. Aucune purge n'a été exécutée.",
|
||||
"choosePolicy": "Choisissez Tout conserver ou au moins un nombre de rétention supérieur à zéro.",
|
||||
"saveFailed": "Impossible d'enregistrer la rétention du stockage. Actualisez la liste si la configuration a changé.",
|
||||
"loadFailed": "Impossible de charger la rétention du stockage.",
|
||||
"empty": "Aucun stockage de sauvegarde trouvé.",
|
||||
"default": "Valeur par défaut de Proxmox",
|
||||
"edit": "Modifier",
|
||||
"editTitle": "Rétention de {storage}",
|
||||
"effect": "S'applique aux futures sauvegardes qui héritent de cette politique de stockage. L'enregistrement ne supprime pas les sauvegardes existantes. Les sauvegardes protégées restent protégées.",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer la rétention",
|
||||
"useDefault": "Utiliser la valeur par défaut de Proxmox (aucune politique sur ce stockage)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -1110,7 +1110,21 @@
|
||||
"available": "Backup disponibili",
|
||||
"loading": "Caricamento backup...",
|
||||
"empty": "Nessun backup trovato",
|
||||
"emptyHint": "Crea il primo backup con il pulsante in alto"
|
||||
"emptyHint": "Crea il primo backup con il pulsante in alto",
|
||||
"refresh": "Aggiorna",
|
||||
"refreshFailed": "Impossibile aggiornare i backup. Aggiorna di nuovo per confermarne lo stato attuale.",
|
||||
"deleteSelected": "Elimina selezionati ({count})",
|
||||
"deletedCount": "{count} backup eliminati.",
|
||||
"unconfirmedCount": "L'eliminazione di {count} backup non è stata confermata. Aggiorna l'elenco e controlla le attività di Proxmox prima di riprovare.",
|
||||
"deleteFailed": "Impossibile confermare l'eliminazione. Aggiorna l'elenco e controlla le attività di Proxmox prima di riprovare.",
|
||||
"selectHint": "Seleziona i backup non protetti (fino a 20 per gruppo)",
|
||||
"selectBackup": "Seleziona backup",
|
||||
"protected": "Protetto",
|
||||
"confirmTitle": "Eliminare {count} backup?",
|
||||
"confirmDescription": "Questi archivi di backup della VM/CT {vmid} verranno eliminati definitivamente. I backup protetti sono esclusi. Questo non elimina l'ospite.",
|
||||
"deleting": "In attesa che Proxmox termini l'eliminazione degli archivi selezionati…",
|
||||
"cancel": "Annulla",
|
||||
"confirmDelete": "Elimina backup"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Aggiornamenti pacchetti in sospeso",
|
||||
@@ -1303,9 +1317,9 @@
|
||||
"Venerdì",
|
||||
"Sabato"
|
||||
],
|
||||
"runSkippedStatus": "corsa saltata",
|
||||
"reasonStoppedContainer": "il contenitore è fermo;l'aggiornamento pianificato è stato saltato.",
|
||||
"reasonStoppedContainerHistorical": "il contenitore è stato arrestato all'ora di aggiornamento pianificata."
|
||||
"runSkippedStatus": "esecuzione saltata",
|
||||
"reasonStoppedContainer": "Il contenitore è fermo; l'aggiornamento pianificato è stato saltato.",
|
||||
"reasonStoppedContainerHistorical": "Il contenitore era fermo all'ora dell'aggiornamento pianificato."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron host rilevato",
|
||||
@@ -2111,7 +2125,13 @@
|
||||
"inactiveFooter": "Abilita le notifiche per ricevere avvisi di sistema, integrità e sicurezza attraverso i canali configurati.",
|
||||
"notificationLanguage": "Lingua notifiche",
|
||||
"selectNotificationLanguage": "Seleziona lingua notifiche",
|
||||
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata."
|
||||
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata.",
|
||||
"appriseServiceHelp": "Configurazione di Slack e Matrix",
|
||||
"appriseSlackSteps": "Crea un webhook in ingresso nell'app Slack per il canale di destinazione, quindi incolla il suo URL HTTPS completo nel campo URL di Apprise. Non è necessaria alcuna conversione.",
|
||||
"appriseSlackDocs": "Formati URL e configurazione di Slack",
|
||||
"appriseMatrixSteps": "Usa un account di notifica autorizzato a pubblicare nella stanza. Sostituisci utente, password, homeserver e ID completo della stanza dell'esempio; codifica per URL i caratteri speciali nelle credenziali. Lo schema matrixs usa HTTPS. Consulta la documentazione per il supporto di token, SSO e stanze cifrate nella versione di Apprise installata.",
|
||||
"appriseMatrixDocs": "Formati URL e configurazione di Matrix",
|
||||
"appriseServiceFinish": "Abilita Apprise, inserisci il tuo URL, scegli le categorie di notifica o i singoli eventi, salva e poi usa Invia test. Questi esempi non modificano l'URL salvato. Questo canale Apprise ha una sola destinazione; usa l'API Apprise per più servizi contemporaneamente."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Miglioramento con IA",
|
||||
@@ -4756,6 +4776,24 @@
|
||||
"thursday": "Giovedì",
|
||||
"tuesday": "Martedì",
|
||||
"wednesday": "Mercoledì"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Conservazione dell'archiviazione",
|
||||
"keepAll": "Mantieni tutti i backup",
|
||||
"description": "Conservazione predefinita dei backup salvata in Proxmox. Le attività di backup con una politica esplicita hanno la precedenza su questo valore.",
|
||||
"refresh": "Aggiorna",
|
||||
"saved": "Politica di conservazione dell'archiviazione salvata. Non è stata eseguita alcuna pulizia.",
|
||||
"choosePolicy": "Scegli Mantieni tutti oppure almeno un numero di conservazione maggiore di zero.",
|
||||
"saveFailed": "Impossibile salvare la politica di conservazione dell'archiviazione. Aggiorna l'elenco se la configurazione è cambiata.",
|
||||
"loadFailed": "Impossibile caricare la politica di conservazione dell'archiviazione.",
|
||||
"empty": "Nessuno spazio di archiviazione dei backup trovato.",
|
||||
"default": "Valore predefinito di Proxmox",
|
||||
"edit": "Modifica",
|
||||
"editTitle": "Conservazione per {storage}",
|
||||
"effect": "Si applica alle future esecuzioni dei backup che ereditano questa politica di archiviazione. Il salvataggio non elimina i backup esistenti. I backup protetti rimangono protetti.",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva conservazione",
|
||||
"useDefault": "Usa l'impostazione predefinita di Proxmox (nessuna policy su questo storage)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -353,7 +353,7 @@
|
||||
"downloadJson": "Baixar JSON",
|
||||
"delete": "Excluir",
|
||||
"confirmDelete": "Excluir este registro de teste?",
|
||||
"note": "Os resultados dos testes são armazenados localmente e usados para gerar relatórios SMART detalhados."
|
||||
"note": "Os resultados dos testes são armazenados localmente e usados para gerar relatórios SMART detalhados."
|
||||
},
|
||||
"smartReport": {
|
||||
"title": "Relatório de saúde SMART",
|
||||
@@ -595,8 +595,8 @@
|
||||
"availableSpare": "Porcentagem de blocos NAND sobressalentes restantes para substituição de blocos defeituosos. Um alerta é acionado abaixo do limite.",
|
||||
"availableSpareThreshold": "Mínimo definido pelo fabricante para peças sobressalentes disponíveis. Abaixo disso, a unidade sinaliza um aviso crítico.",
|
||||
"percentageUsed": "Impulsione a estimativa da resistência consumida com base nos ciclos de gravação reais versus nominais. 100% significa que o TBW avaliado foi atingido; a unidade pode continuar funcionando além disso.",
|
||||
"mediaErrors": "Erros de leitura/gravação irrecuperáveis no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
|
||||
"mediaIntegrityErrors": "Erros irrecuperáveis detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
|
||||
"mediaErrors": "Erros de leitura/gravação irrecuperáveis no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
|
||||
"mediaIntegrityErrors": "Erros irrecuperáveis detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
|
||||
"unsafeShutdowns": "Perdas de energia sem descarga ou desligamento adequado. Contagens muito altas correm o risco de corrupção de metadados e problemas de firmware.",
|
||||
"nvmePowerCycles": "Ciclos totais de ligar/desligar. A ciclagem frequente aumenta o desgaste do conector e do capacitor.",
|
||||
"nvmePowerOnHours": "Total de horas acumuladas em que o inversor esteve ligado desde a fabricação.",
|
||||
@@ -624,7 +624,7 @@
|
||||
"reallocatedSectorCount": "Setores defeituosos substituídos por setores sobressalentes do pool de reserva. Uma contagem crescente significa degradação da unidade.",
|
||||
"retiredBlockCount": "Blocos NAND retirados devido a desgaste ou falha (SSD). Semelhante à contagem de setores realocados para HDDs.",
|
||||
"reallocatedEventCount": "Número de operações de remapeamento realizadas. Cada evento significa que um setor defeituoso foi substituído.",
|
||||
"currentPendingSector": "Setores instáveis esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
|
||||
"currentPendingSector": "Setores instáveis esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
|
||||
"pendingSectors": "Setores esperando para serem remapeados. Eles podem ser resolvidos ou realocados.",
|
||||
"offlineUncorrectable": "Setores que falharam durante a verificação offline e não puderam ser corrigidos. Indica possível perda de dados.",
|
||||
"offlineUncorrectableSectorCount": "Setores incorrigíveis encontrados durante a verificação em segundo plano. Os dados sobre estes setores são perdidos.",
|
||||
@@ -667,7 +667,7 @@
|
||||
"endToEndError": "Corrupção de dados detectada entre o cache do controlador e a interface do host. Deve ser sempre zero.",
|
||||
"endToEndErrorDetectionCount": "Número de erros de paridade no caminho de dados. Diferente de zero indica problemas no controlador.",
|
||||
"wearLevelingCount": "Média de ciclos de apagamento por bloco NAND (SSD). VALOR mais baixo significa mais desgaste consumido.",
|
||||
"wearRangeDelta": "Diferença entre blocos mais usados e menos usados (SSD). Valores altos indicam desgaste irregular.",
|
||||
"wearRangeDelta": "Diferença entre blocos mais usados e menos usados (SSD). Valores altos indicam desgaste irregular.",
|
||||
"mediaWearoutIndicator": "Estimativa de vida útil restante do SSD Intel. Começa em 100 e diminui para 0 conforme a resistência é consumida.",
|
||||
"ssdLifeLeft": "Porcentagem estimada de vida útil restante do SSD com base no desgaste da NAND.",
|
||||
"percentLifetimeRemain": "Porcentagem estimada de vida útil restante. 100 significa novo; 0 significa fim da vida útil nominal.",
|
||||
@@ -744,7 +744,7 @@
|
||||
"nvmeWarningTitle": "Aviso crítico NVMe ativo (0x{value})",
|
||||
"nvmeWarningText": "O controlador NVMe gerou um alerta. Faça backup dos dados imediatamente e investigue mais a fundo.",
|
||||
"nvmeMediaTitle": "Erros de mídia NVMe detectados ({count})",
|
||||
"nvmeMediaText": "Erros irrecuperáveis de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
|
||||
"nvmeMediaText": "Erros irrecuperáveis de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
|
||||
"unsafeTitle": "Contagem alta de desligamentos inseguros ({count})",
|
||||
"unsafeText": "Perdas frequentes de energia aumentam o risco de corrupção do firmware. Garanta energia estável ou use um UPS.",
|
||||
"seagateTitle": "Seagate Raw_Read_Error_Rate - Comportamento normal",
|
||||
@@ -1110,7 +1110,21 @@
|
||||
"available": "Backups disponíveis",
|
||||
"loading": "Carregando backups...",
|
||||
"empty": "Nenhum backup encontrado",
|
||||
"emptyHint": "Crie seu primeiro backup usando o botão acima"
|
||||
"emptyHint": "Crie seu primeiro backup usando o botão acima",
|
||||
"refresh": "Atualizar",
|
||||
"refreshFailed": "Não foi possível atualizar as cópias de segurança. Atualize novamente para confirmar o seu estado atual.",
|
||||
"deleteSelected": "Eliminar selecionadas ({count})",
|
||||
"deletedCount": "{count} cópia(s) de segurança eliminada(s).",
|
||||
"unconfirmedCount": "A eliminação de {count} cópia(s) de segurança não foi confirmada. Atualize a lista e verifique as tarefas do Proxmox antes de tentar novamente.",
|
||||
"deleteFailed": "Não foi possível confirmar a eliminação. Atualize a lista e verifique as tarefas do Proxmox antes de tentar novamente.",
|
||||
"selectHint": "Selecione cópias de segurança sem proteção (até 20 por lote)",
|
||||
"selectBackup": "Selecionar cópia de segurança",
|
||||
"protected": "Protegida",
|
||||
"confirmTitle": "Eliminar {count} cópia(s) de segurança?",
|
||||
"confirmDescription": "Estes arquivos de cópia de segurança da VM/CT {vmid} serão eliminados permanentemente. As cópias protegidas estão excluídas. Isto não elimina o convidado.",
|
||||
"deleting": "À espera que o Proxmox termine de eliminar os arquivos selecionados…",
|
||||
"cancel": "Cancelar",
|
||||
"confirmDelete": "Eliminar cópias de segurança"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Atualizações de pacote pendentes",
|
||||
@@ -1304,8 +1318,8 @@
|
||||
"Sábado"
|
||||
],
|
||||
"runSkippedStatus": "execução ignorada",
|
||||
"reasonStoppedContainer": "Container está parado;a atualização agendada foi ignorada.",
|
||||
"reasonStoppedContainerHistorical": "o contêiner foi interrompido no horário de atualização agendado."
|
||||
"reasonStoppedContainer": "O contêiner está parado; a atualização agendada foi ignorada.",
|
||||
"reasonStoppedContainerHistorical": "O contêiner estava parado no horário da atualização agendada."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron do host detectado",
|
||||
@@ -1771,7 +1785,7 @@
|
||||
},
|
||||
"healthMonitor": {
|
||||
"title": "Monitor de Saúde",
|
||||
"description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
|
||||
"description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
|
||||
"category": "Categoria",
|
||||
"suppressionDuration": "Duração da Supressão",
|
||||
"hours": "Horas",
|
||||
@@ -1781,7 +1795,7 @@
|
||||
"footerNote": "Essas configurações se aplicam quando você descarta um aviso do Health Monitor. Alertas críticos de temperatura da CPU sempre aparecem para proteger seu hardware.",
|
||||
"activeSuppressions": "Supressões Ativas",
|
||||
"activeSuppressionsDescription": "Alertas que você ocultou do Health Monitor. As demissões permanentes só podem ser revertidas aqui. A edição requer o Health Monitor",
|
||||
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados do Health Monitor aparecerão aqui.",
|
||||
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados do Health Monitor aparecerão aqui.",
|
||||
"permanent": "Permanente",
|
||||
"daysRemaining": "{count}d restantes",
|
||||
"hoursRemaining": "{count}h restantes",
|
||||
@@ -2111,7 +2125,13 @@
|
||||
"inactiveFooter": "Habilite notificações para receber alertas de sistema, integridade e segurança por meio dos canais configurados.",
|
||||
"notificationLanguage": "Idioma de notificação",
|
||||
"selectNotificationLanguage": "Selecione o idioma de notificação",
|
||||
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada."
|
||||
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada.",
|
||||
"appriseServiceHelp": "Configuração do Slack e do Matrix",
|
||||
"appriseSlackSteps": "Crie um webhook de entrada na sua aplicação Slack para o canal de destino e cole o seu URL HTTPS completo no campo URL do Apprise. Não é necessária qualquer conversão.",
|
||||
"appriseSlackDocs": "Formatos de URL e configuração do Slack",
|
||||
"appriseMatrixSteps": "Utilize uma conta de notificações que possa publicar na sala. Substitua o utilizador, a palavra-passe, o homeserver e o ID completo da sala do exemplo; codifique os caracteres especiais das credenciais para URL. O esquema matrixs usa HTTPS. Consulte a documentação sobre o suporte de tokens, SSO e salas cifradas na versão do Apprise instalada.",
|
||||
"appriseMatrixDocs": "Formatos de URL e configuração do Matrix",
|
||||
"appriseServiceFinish": "Ative o Apprise, introduza o seu URL, escolha as categorias de notificação ou os eventos individuais, guarde e utilize Enviar teste. Estes exemplos não alteram o URL guardado. Este canal do Apprise tem um destino; utilize a API do Apprise para vários serviços em simultâneo."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Aprimoramento de IA",
|
||||
@@ -3567,7 +3587,7 @@
|
||||
"hour": "Passada 1h",
|
||||
"day": "Passadas 24 horas",
|
||||
"week": "Passado 7 dias",
|
||||
"month": "Passados 30 dias",
|
||||
"month": "Passados 30 dias",
|
||||
"year": "Último 1 ano"
|
||||
},
|
||||
"last": {
|
||||
@@ -4019,7 +4039,7 @@
|
||||
},
|
||||
"backends": {
|
||||
"borgDescription": "Repositório Borg para backups de host desduplicados.",
|
||||
"borgDescriptionTimerOnly": "Os destinos Borg são usados pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
|
||||
"borgDescriptionTimerOnly": "Os destinos Borg são usados pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
|
||||
"borgShortDescription": "Repositório Borg desduplicado",
|
||||
"local": "Local",
|
||||
"localDescription": "Pasta neste host ou em uma unidade USB montada.",
|
||||
@@ -4547,7 +4567,7 @@
|
||||
"customPickDescriptionMiddle": "através do",
|
||||
"destructiveTitle": "A restauração completa REMOVERÁ o seguinte (criado após o backup)",
|
||||
"noPathsMatch": "Nenhum caminho corresponde ao filtro.",
|
||||
"noRestorablePaths": "Este backup não contém caminhos restauráveis — apenas a restauração completa é significativa aqui.",
|
||||
"noRestorablePaths": "Este backup não contém caminhos restauráveis — apenas a restauração completa é significativa aqui.",
|
||||
"olderThanTargetKernel": "é mais antigo que o kernel alvo",
|
||||
"operatorConfigDescription": "As seguintes configurações criadas pelo operador serão mescladas no destino automaticamente, sem alterar os padrões vinculados ao kernel. É executado na restauração completa e personalizada.",
|
||||
"operatorConfigTitle": "Configuração do operador reaplicada por meio de mesclagem independente de kernel",
|
||||
@@ -4756,6 +4776,24 @@
|
||||
"thursday": "Quinta-feira",
|
||||
"tuesday": "Terça-feira",
|
||||
"wednesday": "Quarta-feira"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Retenção do armazenamento",
|
||||
"keepAll": "Manter todas as cópias de segurança",
|
||||
"description": "Retenção predefinida de cópias de segurança guardada no Proxmox. As tarefas de cópia com uma política explícita substituem este valor predefinido.",
|
||||
"refresh": "Atualizar",
|
||||
"saved": "Retenção do armazenamento guardada. Não foi executada nenhuma limpeza.",
|
||||
"choosePolicy": "Escolha Manter todas ou pelo menos uma quantidade de retenção superior a zero.",
|
||||
"saveFailed": "Não foi possível guardar a retenção do armazenamento. Atualize a lista se a configuração tiver mudado.",
|
||||
"loadFailed": "Não foi possível carregar a retenção do armazenamento.",
|
||||
"empty": "Não foram encontrados armazenamentos de cópias de segurança.",
|
||||
"default": "Predefinição do Proxmox",
|
||||
"edit": "Editar",
|
||||
"editTitle": "Retenção para {storage}",
|
||||
"effect": "Aplica-se às futuras cópias de segurança que herdem esta política de armazenamento. Guardar não elimina as cópias existentes. As cópias protegidas permanecem protegidas.",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar retenção",
|
||||
"useDefault": "Use o padrão Proxmox (nenhuma política neste armazenamento)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4912,7 +4950,7 @@
|
||||
},
|
||||
"noIssues": "Nenhum problema detectado",
|
||||
"updateNow": "Atualizar agora",
|
||||
"dismissedItems": "Itens dispensados ({count})",
|
||||
"dismissedItems": "Itens dispensados ({count})",
|
||||
"permanent": "Permanente",
|
||||
"dismissed": "Dispensado",
|
||||
"wasStatus": "era {status}",
|
||||
|
||||
@@ -1131,7 +1131,21 @@
|
||||
"available": "Dostupné zálohy",
|
||||
"loading": "Načítavam zálohy...",
|
||||
"empty": "Nenašli sa žiadne zálohy",
|
||||
"emptyHint": "Prvú zálohu vytvoríte tlačidlom vyššie"
|
||||
"emptyHint": "Prvú zálohu vytvoríte tlačidlom vyššie",
|
||||
"refresh": "Obnoviť",
|
||||
"refreshFailed": "Nepodarilo sa obnoviť zálohy. Obnovte ich znova, aby ste potvrdili ich aktuálny stav.",
|
||||
"deleteSelected": "Odstrániť vybrané ({count})",
|
||||
"deletedCount": "Odstránené zálohy: {count}.",
|
||||
"unconfirmedCount": "Odstránenie záloh ({count}) nebolo potvrdené. Pred ďalším pokusom obnovte zoznam a skontrolujte úlohy v Proxmoxe.",
|
||||
"deleteFailed": "Odstránenie sa nepodarilo potvrdiť. Pred ďalším pokusom obnovte zoznam a skontrolujte úlohy v Proxmoxe.",
|
||||
"selectHint": "Vyberte nechránené zálohy (najviac 20 v jednej dávke)",
|
||||
"selectBackup": "Vybrať zálohu",
|
||||
"protected": "Chránená",
|
||||
"confirmTitle": "Odstrániť zálohy ({count})?",
|
||||
"confirmDescription": "Tieto archívy záloh pre VM/CT {vmid} budú natrvalo odstránené. Chránené zálohy sú vylúčené. Hosť sa tým neodstráni.",
|
||||
"deleting": "Čaká sa, kým Proxmox dokončí odstránenie vybraných archívov…",
|
||||
"cancel": "Zrušiť",
|
||||
"confirmDelete": "Odstrániť zálohy"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Čakajúce aktualizácie balíkov",
|
||||
@@ -2110,7 +2124,13 @@
|
||||
"inactiveFooter": "Zapnite notifikácie a dostávajte upozornenia o systéme, stave a bezpečnosti cez nastavené kanály.",
|
||||
"notificationLanguage": "Jazyk notifikácií",
|
||||
"selectNotificationLanguage": "Vyberte jazyk notifikácií",
|
||||
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá."
|
||||
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá.",
|
||||
"appriseServiceHelp": "Nastavenie Slack a Matrix",
|
||||
"appriseSlackSteps": "V aplikácii Slack vytvorte prichádzajúci webhook pre cieľový kanál a vložte jeho úplnú HTTPS URL do poľa URL Apprise. Nie je potrebný žiadny prevod.",
|
||||
"appriseSlackDocs": "Formáty URL a nastavenie Slack",
|
||||
"appriseMatrixSteps": "Použite účet pre oznámenia, ktorý môže odosielať správy do miestnosti. Nahraďte vzorového používateľa, heslo, domovský server a úplné ID miestnosti; špeciálne znaky v prihlasovacích údajoch zakódujte pre URL. Schéma matrixs používa HTTPS. Podporu tokenov, SSO a šifrovaných miestností v nainštalovanej verzii Apprise nájdete v dokumentácii.",
|
||||
"appriseMatrixDocs": "Formáty URL a nastavenie Matrix",
|
||||
"appriseServiceFinish": "Povoľte Apprise, zadajte svoju URL, vyberte kategórie oznámení alebo jednotlivé udalosti, uložte a potom použite Odoslať test. Tieto príklady nemenia uloženú URL. Tento kanál Apprise má jeden cieľ; pre viac služieb naraz použite API Apprise."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI vylepšenie",
|
||||
@@ -4755,6 +4775,24 @@
|
||||
"thursday": "štvrtok",
|
||||
"tuesday": "utorok",
|
||||
"wednesday": "streda"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Uchovávanie záloh úložiska",
|
||||
"keepAll": "Ponechať všetky zálohy",
|
||||
"description": "Predvolená politika uchovávania záloh uložená v Proxmoxe. Zálohovacie úlohy s vlastnou politikou majú pred týmto predvoleným nastavením prednosť.",
|
||||
"refresh": "Obnoviť",
|
||||
"saved": "Politika uchovávania záloh úložiska bola uložená. Prerezávanie sa nevykonalo.",
|
||||
"choosePolicy": "Vyberte Ponechať všetky alebo aspoň jeden počet uchovávaných záloh väčší ako nula.",
|
||||
"saveFailed": "Nepodarilo sa uložiť politiku uchovávania záloh úložiska. Ak sa konfigurácia zmenila, obnovte zoznam.",
|
||||
"loadFailed": "Nepodarilo sa načítať politiku uchovávania záloh úložiska.",
|
||||
"empty": "Nenašli sa žiadne úložiská záloh.",
|
||||
"default": "Predvolené nastavenie Proxmoxu",
|
||||
"edit": "Upraviť",
|
||||
"editTitle": "Uchovávanie záloh pre {storage}",
|
||||
"effect": "Platí pre budúce zálohovania, ktoré preberajú túto politiku úložiska. Uložením sa existujúce zálohy neodstránia. Chránené zálohy zostanú chránené.",
|
||||
"cancel": "Zrušiť",
|
||||
"save": "Uložiť uchovávanie záloh",
|
||||
"useDefault": "Použite predvolené nastavenie Proxmox (žiadne pravidlá pre toto úložisko)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -1110,7 +1110,21 @@
|
||||
"available": "Tillgängliga säkerhetskopior",
|
||||
"loading": "Laddar säkerhetskopior...",
|
||||
"empty": "Inga säkerhetskopior hittades",
|
||||
"emptyHint": "Skapa din första säkerhetskopia med knappen ovan"
|
||||
"emptyHint": "Skapa din första säkerhetskopia med knappen ovan",
|
||||
"refresh": "Uppdatera",
|
||||
"refreshFailed": "Det gick inte att uppdatera säkerhetskopiorna. Uppdatera igen för att bekräfta deras aktuella tillstånd.",
|
||||
"deleteSelected": "Ta bort markerade ({count})",
|
||||
"deletedCount": "{count} säkerhetskopior har tagits bort.",
|
||||
"unconfirmedCount": "Borttagning av {count} säkerhetskopior har inte bekräftats. Uppdatera listan och kontrollera Proxmox uppgifter innan du försöker igen.",
|
||||
"deleteFailed": "Borttagningen kunde inte bekräftas. Uppdatera listan och kontrollera Proxmox uppgifter innan du försöker igen.",
|
||||
"selectHint": "Välj oskyddade säkerhetskopior (upp till 20 per omgång)",
|
||||
"selectBackup": "Välj säkerhetskopia",
|
||||
"protected": "Skyddad",
|
||||
"confirmTitle": "Ta bort {count} säkerhetskopior?",
|
||||
"confirmDescription": "Dessa säkerhetskopieringsarkiv för VM/CT {vmid} kommer att tas bort permanent. Skyddade säkerhetskopior är undantagna. Detta tar inte bort gästen.",
|
||||
"deleting": "Väntar på att Proxmox ska slutföra borttagningen av de valda arkiven…",
|
||||
"cancel": "Avbryt",
|
||||
"confirmDelete": "Ta bort säkerhetskopior"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Väntande paketuppdateringar",
|
||||
@@ -1303,8 +1317,8 @@
|
||||
"fredag",
|
||||
"lördag"
|
||||
],
|
||||
"runSkippedStatus": "kör hoppade över",
|
||||
"reasonStoppedContainer": "Behållaren är stoppad;schemalagd uppdatering hoppades över.",
|
||||
"runSkippedStatus": "körning överhoppad",
|
||||
"reasonStoppedContainer": "Behållaren är stoppad; den schemalagda uppdateringen hoppades över.",
|
||||
"reasonStoppedContainerHistorical": "Behållaren stoppades vid den schemalagda uppdateringstiden."
|
||||
},
|
||||
"cronChip": {
|
||||
@@ -2111,7 +2125,13 @@
|
||||
"inactiveFooter": "Aktivera aviseringar för att ta emot system-, hälso- och säkerhetsvarningar via de konfigurerade kanalerna.",
|
||||
"notificationLanguage": "Aviseringsspråk",
|
||||
"selectNotificationLanguage": "Välj språk för meddelanden",
|
||||
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat."
|
||||
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat.",
|
||||
"appriseServiceHelp": "Konfiguration av Slack och Matrix",
|
||||
"appriseSlackSteps": "Skapa en inkommande webhook i din Slack-app för målkanalen och klistra sedan in dess fullständiga HTTPS-URL i fältet Apprise-URL. Ingen omvandling behövs.",
|
||||
"appriseSlackDocs": "URL-format och konfiguration för Slack",
|
||||
"appriseMatrixSteps": "Använd ett aviseringskonto som får skicka meddelanden till rummet. Ersätt exemplets användare, lösenord, hemserver och fullständiga rums-ID; URL-koda specialtecken i inloggningsuppgifterna. Schemat matrixs använder HTTPS. Se dokumentationen för stöd för token, SSO och krypterade rum i din installerade Apprise-version.",
|
||||
"appriseMatrixDocs": "URL-format och konfiguration för Matrix",
|
||||
"appriseServiceFinish": "Aktivera Apprise, ange din URL, välj aviseringskategorier eller enskilda händelser, spara och använd sedan Skicka test. Dessa exempel ändrar inte din sparade URL. Denna Apprise-kanal har ett mål; använd Apprise-API för flera tjänster samtidigt."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI-förbättring",
|
||||
@@ -4756,6 +4776,24 @@
|
||||
"thursday": "torsdag",
|
||||
"tuesday": "tisdag",
|
||||
"wednesday": "onsdag"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Lagringens bevarandepolicy",
|
||||
"keepAll": "Behåll alla säkerhetskopior",
|
||||
"description": "Standardpolicy för bevarande av säkerhetskopior, sparad i Proxmox. Säkerhetskopieringsjobb med en uttrycklig policy åsidosätter denna standard.",
|
||||
"refresh": "Uppdatera",
|
||||
"saved": "Lagringens bevarandepolicy har sparats. Ingen gallring har körts.",
|
||||
"choosePolicy": "Välj Behåll alla eller minst ett bevarandeantal som är större än noll.",
|
||||
"saveFailed": "Det gick inte att spara lagringens bevarandepolicy. Uppdatera listan om konfigurationen har ändrats.",
|
||||
"loadFailed": "Det gick inte att läsa in lagringens bevarandepolicy.",
|
||||
"empty": "Ingen lagring för säkerhetskopior hittades.",
|
||||
"default": "Proxmox standard",
|
||||
"edit": "Redigera",
|
||||
"editTitle": "Bevarande för {storage}",
|
||||
"effect": "Gäller framtida säkerhetskopieringar som ärver denna lagringspolicy. Att spara tar inte bort befintliga säkerhetskopior. Skyddade säkerhetskopior förblir skyddade.",
|
||||
"cancel": "Avbryt",
|
||||
"save": "Spara bevarandepolicy",
|
||||
"useDefault": "Använd Proxmox-standard (ingen policy för denna lagring)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -184,6 +184,7 @@ done
|
||||
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
|
||||
cp "$SCRIPT_DIR/flask_vm_backup_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_vm_backup_routes.py not found"
|
||||
cp "$SCRIPT_DIR/guest_backup_archives.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
|
||||
cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found"
|
||||
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
|
||||
|
||||
@@ -35,7 +35,7 @@ TOOL_METADATA = {
|
||||
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
|
||||
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
|
||||
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.2'},
|
||||
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
|
||||
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
|
||||
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
|
||||
|
||||
@@ -97,6 +97,8 @@ from flask_notification_routes import notification_bp # noqa: E402
|
||||
from flask_oci_routes import oci_bp # noqa: E402
|
||||
from flask_audit_routes import audit_bp # noqa: E402
|
||||
from flask_vm_backup_routes import vm_backup_bp # noqa: E402
|
||||
from guest_backup_archives import (BackupArchiveError, delete_guest_backups,
|
||||
guest_backup_deletion_status) # noqa: E402
|
||||
from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402
|
||||
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
|
||||
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
|
||||
@@ -13309,6 +13311,32 @@ def api_create_backup(vmid):
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/backups/delete', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def api_delete_vm_backups(vmid):
|
||||
"""Delete an explicitly confirmed selection, revalidated by Proxmox."""
|
||||
try:
|
||||
return jsonify(delete_guest_backups(vmid, request.get_json(silent=True)))
|
||||
except BackupArchiveError as error:
|
||||
return jsonify({'error': str(error)}), error.code
|
||||
finally:
|
||||
# Native deletion may have started even if the pvesh response was lost.
|
||||
_vm_cache_invalidate(vmid, _vm_backups_cache)
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/backups/delete-status', methods=['GET'])
|
||||
@require_admin_scope
|
||||
def api_vm_backup_deletion_status(vmid):
|
||||
"""Only poll deletion tasks started here for this guest."""
|
||||
try:
|
||||
result = guest_backup_deletion_status(vmid, request.args.get('task'))
|
||||
if result['status'] != 'pending':
|
||||
_vm_cache_invalidate(vmid, _vm_backups_cache)
|
||||
return jsonify(result)
|
||||
except BackupArchiveError as error:
|
||||
return jsonify({'error': str(error)}), error.code
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_backups(vmid):
|
||||
@@ -13389,6 +13417,7 @@ def api_vm_backups(vmid):
|
||||
'timestamp': ctime,
|
||||
'date': datetime.fromtimestamp(ctime).strftime('%Y-%m-%d %H:%M') if ctime else '',
|
||||
'notes': item.get('notes', ''),
|
||||
'protected': item.get('protected', False) not in (False, 0, '0', '', None),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -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,58 @@ 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")
|
||||
# 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")
|
||||
pairs = [part.split("=", 1) for part in prune.split(",")] if prune else []
|
||||
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
|
||||
any(int(value) < 1 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)
|
||||
change = ["--prune-backups", prune] if prune else ["--delete", "prune-backups"]
|
||||
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, *change])
|
||||
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():
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# ==========================================================
|
||||
# ProxMenux Monitor - Guest backup archives
|
||||
# Author : Jonatan
|
||||
# GitHub : github.com/jcastro
|
||||
# Maintainer : MacRimi
|
||||
# Copyright : (c) 2026 MacRimi & contributors
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 16/10/2026
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# Deletes explicitly selected VM/CT backups through the native Proxmox
|
||||
# storage API after a fresh inventory/protection check. Tracks native
|
||||
# deletion tasks so an accepted operation is not mistaken for completion.
|
||||
# No filesystem paths or cached inventory authorize deletion.
|
||||
# ==========================================================
|
||||
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
|
||||
MAX_SELECTION = 20
|
||||
REMOVED_RE = re.compile(r"Removed volume '[^'\n]+'")
|
||||
STORAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
||||
NODE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}")
|
||||
_tasks = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class BackupArchiveError(Exception):
|
||||
def __init__(self, message, code=400):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _pvesh(args, timeout=20):
|
||||
try:
|
||||
result = subprocess.run(["pvesh", *args, "--output-format", "json"],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BackupArchiveError("Proxmox command unavailable or timed out; outcome unconfirmed", 502) from error
|
||||
if result.returncode:
|
||||
raise BackupArchiveError((result.stderr or "Proxmox command failed").strip()[:1000], 502)
|
||||
output = result.stdout.strip()
|
||||
try:
|
||||
# pvesh can print no bytes for the API's successful null result.
|
||||
return json.loads(output or "null")
|
||||
except (ValueError, TypeError) as error:
|
||||
# With --delay, a deletion that ends in time prints the line of its
|
||||
# task log instead of JSON. Only that exact line is accepted.
|
||||
if args[0] == "delete" and REMOVED_RE.fullmatch(output):
|
||||
return output
|
||||
raise BackupArchiveError("Invalid Proxmox response; outcome unconfirmed", 502) from error
|
||||
|
||||
|
||||
def _node():
|
||||
# Same local storage/node scope as the guest's existing backup list.
|
||||
node = socket.gethostname().split(".")[0]
|
||||
if not NODE_RE.fullmatch(node):
|
||||
raise BackupArchiveError("Invalid local node name", 502)
|
||||
return node
|
||||
|
||||
|
||||
def _selection(vmid, data):
|
||||
if not 100 <= vmid <= 999999999:
|
||||
raise BackupArchiveError("Invalid VM/CT ID")
|
||||
if not isinstance(data, dict) or data.get("confirm") is not True:
|
||||
raise BackupArchiveError("Explicit confirmation is required")
|
||||
selected = data.get("backups")
|
||||
if not isinstance(selected, list) or not 1 <= len(selected) <= MAX_SELECTION:
|
||||
raise BackupArchiveError(f"Select between 1 and {MAX_SELECTION} backups")
|
||||
seen = set()
|
||||
for item in selected:
|
||||
if not isinstance(item, dict):
|
||||
raise BackupArchiveError("Invalid backup selection")
|
||||
storage, volid = item.get("storage"), item.get("volid")
|
||||
if not isinstance(storage, str) or not STORAGE_RE.fullmatch(storage):
|
||||
raise BackupArchiveError("Invalid backup storage")
|
||||
if (not isinstance(volid, str) or not volid.startswith(storage + ":")
|
||||
or len(volid) > 512 or not re.fullmatch(r"[A-Za-z0-9_:./+@-]+", volid)
|
||||
or any(part in ("", ".", "..") for part in volid.split(":", 1)[1].split("/"))):
|
||||
raise BackupArchiveError("Invalid backup volume ID")
|
||||
if volid in seen:
|
||||
raise BackupArchiveError("Duplicate backup selection")
|
||||
seen.add(volid)
|
||||
return [{"storage": item["storage"], "volid": item["volid"]} for item in selected]
|
||||
|
||||
|
||||
def _protected(value):
|
||||
# Fail closed for an unexpected protection value, including the text 'true'.
|
||||
return value not in (None, False, 0, "0", "")
|
||||
|
||||
|
||||
def _remember_task(task, vmid, node, volid):
|
||||
# These are server-returned task IDs, never arbitrary client paths.
|
||||
if (not isinstance(task, str) or not task.startswith(f"UPID:{node}:")
|
||||
or ":imgdel:" not in task or "/" in task or len(task) > 1024):
|
||||
raise BackupArchiveError("Unexpected deletion task; outcome unconfirmed", 502)
|
||||
with _lock:
|
||||
now = time.monotonic()
|
||||
expired = [key for key, entry in _tasks.items() if now - entry["created"] > 3600]
|
||||
for key in expired:
|
||||
del _tasks[key]
|
||||
# Bound even failed/unpolled jobs; expiry does not cancel a PVE task.
|
||||
if len(_tasks) >= 512:
|
||||
del _tasks[min(_tasks, key=lambda key: _tasks[key]["created"])]
|
||||
_tasks[task] = {"vmid": vmid, "node": node, "volid": volid, "created": now}
|
||||
|
||||
|
||||
def delete_guest_backups(vmid, data):
|
||||
selected = _selection(vmid, data)
|
||||
node = _node()
|
||||
inventory = {}
|
||||
# Preflight the entire batch before deleting anything. An unavailable
|
||||
# storage or a protected/stale/cross-guest selection fails the whole batch.
|
||||
for storage in sorted({item["storage"] for item in selected}):
|
||||
entries = _pvesh(["get", f"/nodes/{node}/storage/{storage}/content",
|
||||
"--content", "backup", "--vmid", str(vmid)])
|
||||
if not isinstance(entries, list):
|
||||
raise BackupArchiveError("Invalid backup inventory", 502)
|
||||
for entry in entries:
|
||||
if (isinstance(entry, dict) and entry.get("content") == "backup"
|
||||
and isinstance(entry.get("volid"), str)):
|
||||
inventory[(storage, entry.get("volid"))] = entry
|
||||
for item in selected:
|
||||
entry = inventory.get((item["storage"], item["volid"]))
|
||||
if entry is None or str(entry.get("vmid")) != str(vmid):
|
||||
raise BackupArchiveError("Backup no longer exists or does not belong to this VM/CT", 409)
|
||||
if _protected(entry.get("protected")):
|
||||
raise BackupArchiveError("Protected backups cannot be deleted", 409)
|
||||
|
||||
def remove(item):
|
||||
try:
|
||||
# Proxmox also enforces protection at deletion time. It removes
|
||||
# associated notes/log files and returns null only after success.
|
||||
task = _pvesh(["delete", f'/nodes/{node}/storage/{item["storage"]}/content/{item["volid"]}',
|
||||
"--delay", "1"])
|
||||
if isinstance(task, str) and task.startswith("UPID:"):
|
||||
_remember_task(task, vmid, node, item["volid"])
|
||||
return {**item, "status": "pending", "task": task}
|
||||
if task is None or task == f"Removed volume '{item['volid']}'":
|
||||
return {**item, "status": "deleted"}
|
||||
return {**item, "status": "unconfirmed", "error": "Invalid Proxmox response; outcome unconfirmed"}
|
||||
except BackupArchiveError as error:
|
||||
return {**item, "status": "unconfirmed", "error": str(error)}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(selected)), thread_name_prefix="backup-delete") as pool:
|
||||
results = list(pool.map(remove, selected))
|
||||
return {"results": results}
|
||||
|
||||
|
||||
def guest_backup_deletion_status(vmid, task):
|
||||
with _lock:
|
||||
entry = _tasks.get(task) if isinstance(task, str) else None
|
||||
if not entry or entry["vmid"] != vmid or time.monotonic() - entry["created"] > 3600:
|
||||
raise BackupArchiveError("Deletion task unknown or expired; refresh the backup list", 409)
|
||||
entry = entry.copy()
|
||||
status = _pvesh(["get", f'/nodes/{entry["node"]}/tasks/{task}/status'])
|
||||
if not isinstance(status, dict) or status.get("status") not in ("running", "stopped"):
|
||||
raise BackupArchiveError("Invalid deletion task status", 502)
|
||||
result = {"volid": entry["volid"], "status": "pending"}
|
||||
if status["status"] == "stopped":
|
||||
if status.get("exitstatus") == "OK":
|
||||
result["status"] = "deleted"
|
||||
else:
|
||||
result.update(status="unconfirmed", error=status.get("exitstatus") or "Deletion outcome unconfirmed")
|
||||
return result
|
||||
@@ -1564,7 +1564,7 @@ class AppriseChannel(NotificationChannel):
|
||||
return apprise_mod.NotifyType.FAILURE
|
||||
if sev == 'WARNING':
|
||||
return apprise_mod.NotifyType.WARNING
|
||||
if sev == 'SUCCESS':
|
||||
if sev in ('SUCCESS', 'OK'):
|
||||
return apprise_mod.NotifyType.SUCCESS
|
||||
return apprise_mod.NotifyType.INFO
|
||||
|
||||
|
||||
@@ -24,3 +24,46 @@ for (const [data, error, expected] of [
|
||||
assert.equal(sandbox.exports.ProxmoxDashboard().type, expected)
|
||||
}
|
||||
console.log('PASS: PBS dashboard routing and loading gate do not mount PVE prewarmers')
|
||||
|
||||
// The PVE Backup tab keeps native guest retention alongside jobs when adding
|
||||
// its PBS connection view. Exercise the shipped JSX for all three selections.
|
||||
const backupSource = fs.readFileSync(path.join(__dirname, '../components/backup-tab.tsx'), 'utf8')
|
||||
const backupTree = ts.createSourceFile('backup-tab.tsx', backupSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const backupTab = backupTree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'BackupTab')
|
||||
assert.ok(backupTab, 'Backup tab must keep a platform-specific view switch')
|
||||
const backupCode = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, backupTab, backupTree), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText
|
||||
const HostBackup = () => {}, VmBackupJobs = () => {}, BackupStorageRetention = () => {}, PBSPanel = () => {}
|
||||
for (const [view, expected] of [
|
||||
['host', [HostBackup]], ['guests', [VmBackupJobs, BackupStorageRetention]], ['pbs', [PBSPanel]],
|
||||
]) {
|
||||
const selected = []
|
||||
const sandbox = {
|
||||
exports: {}, HostBackup, VmBackupJobs, BackupStorageRetention, PBSPanel, DatabaseBackup: () => {},
|
||||
useT: () => key => key, useState: () => [view, value => selected.push(value)],
|
||||
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }), Fragment: Symbol('Fragment') }),
|
||||
}
|
||||
vm.runInNewContext(backupCode, sandbox)
|
||||
const visible = [], mountedHosts = [], buttons = []
|
||||
const components = new Set([HostBackup, VmBackupJobs, BackupStorageRetention, PBSPanel])
|
||||
function walk(node, hidden = false) {
|
||||
if (Array.isArray(node)) { node.forEach(item => walk(item, hidden)); return }
|
||||
if (!node || typeof node !== 'object') return
|
||||
hidden ||= node.props?.className === 'hidden'
|
||||
if (node.type === HostBackup) mountedHosts.push(node)
|
||||
if (components.has(node.type) && !hidden) visible.push(node.type)
|
||||
if (node.type === 'button') buttons.push(node)
|
||||
walk(node.props?.children, hidden)
|
||||
}
|
||||
walk(sandbox.exports.BackupTab())
|
||||
assert.deepEqual(visible, expected, `visible backup panels for ${view}`)
|
||||
assert.equal(mountedHosts.length, 1, 'host backup progress must stay mounted across view changes')
|
||||
assert.equal(buttons.length, 3)
|
||||
assert.equal(buttons.filter(button => button.props['aria-pressed']).length, 1)
|
||||
const pbsButton = buttons.find(button => button.props.children === 'pbs.title')
|
||||
assert.ok(pbsButton, 'PBS navigation button must exist')
|
||||
pbsButton.props.onClick()
|
||||
assert.deepEqual(selected, ['pbs'])
|
||||
}
|
||||
console.log('PASS: host progress stays mounted, guest jobs and retention stay together, and PBS navigation remains separate')
|
||||
|
||||
Reference in New Issue
Block a user