mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
feat(backup): manage the VM and CT backup jobs of Proxmox
Co-authored-by: MattiaC46 <52442052+MattiaC46@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
// The Backup tab: the backups of the host and the backup jobs of the VMs
|
||||
// and LXCs, each in its own view.
|
||||
|
||||
import { useState } from "react"
|
||||
import { DatabaseBackup } from "lucide-react"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type BackupView = "host" | "guests"
|
||||
|
||||
export function BackupTab() {
|
||||
const t = useT()
|
||||
const [view, setView] = useState<BackupView>("host")
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseBackup className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("navigation.backup")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("backup.viewSwitch.ariaLabel")}
|
||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||
sm:inline-flex sm:w-auto"
|
||||
>
|
||||
{(["host", "guests"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={view === key}
|
||||
onClick={() => setView(key)}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
|
||||
view === key
|
||||
? "bg-blue-500 text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`backup.viewSwitch.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* The host view stays mounted: it follows a running backup or restore. */}
|
||||
<div className={view === "host" ? "" : "hidden"}>
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
@@ -289,7 +290,7 @@ export function CustomLinkEditor({
|
||||
</Button>
|
||||
) : <div />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("apps.customLinkCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -49,7 +49,7 @@ import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { RestoreProgressCard } from "./restore-progress-card"
|
||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||
import { formatStorage, formatBytes } from "../lib/utils"
|
||||
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
||||
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
||||
import { useI18n, useT } from "../lib/i18n/provider"
|
||||
|
||||
@@ -1513,7 +1513,7 @@ export function HostBackup() {
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline" className={cancelButtonClass}
|
||||
onClick={() => setJobToDelete(null)}
|
||||
disabled={busyJobId === jobToDelete?.id}
|
||||
>
|
||||
@@ -2308,7 +2308,7 @@ function InspectModal({
|
||||
</DialogHeader>
|
||||
{kf && <KeyfileErrorBlock err={kf} />}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -2414,7 +2414,7 @@ function InspectModal({
|
||||
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={deleteArchive} disabled={deletingArchive}>
|
||||
@@ -2445,7 +2445,7 @@ function InspectModal({
|
||||
{archiveLog?.content ?? ""}
|
||||
</pre>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowArchiveFullLog(false)}>{t("actions.close")}</Button>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowArchiveFullLog(false)}>{t("actions.close")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -4316,7 +4316,7 @@ function CreateJobDialog({
|
||||
{/* Footer navigation */}
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline" className={cancelButtonClass}
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
@@ -5213,7 +5213,7 @@ function ManualBackupDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={submitting}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -5728,7 +5728,7 @@ function DestinationsSection({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setConfirmingDest(null)}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setConfirmingDest(null)}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDeleteDest}>
|
||||
@@ -6052,7 +6052,7 @@ function ConfigureDestinationWizard({
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={onClose}>{t("actions.cancel")}</Button>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose}>{t("actions.cancel")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -6613,7 +6613,7 @@ function AddDestinationDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-border">
|
||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!canSubmit || submitting}
|
||||
@@ -6862,7 +6862,7 @@ function UsbPicker({
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline" className={cancelButtonClass}
|
||||
onClick={() => { setFormatTarget(null); setFormatTyped("") }}
|
||||
disabled={busyKey === formatTarget?.path_or_device}
|
||||
>
|
||||
@@ -7285,7 +7285,7 @@ function UsbDrivesSection() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -7781,7 +7781,7 @@ function JobDetailModal({
|
||||
{detail?.id}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowDisableConfirm(false)}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowDisableConfirm(false)}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -8164,7 +8164,7 @@ function PbsKeyfileRecoveryDialog({
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={busy}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -9039,7 +9039,7 @@ function RestoreOptionsModal({
|
||||
{t("backup.actions.back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
{step === "custom" && (
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Settings } from "./settings"
|
||||
import { Security } from "./security"
|
||||
import { Profile } from "./profile"
|
||||
import { About } from "./about"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { BackupTab } from "./backup-tab"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -849,7 +849,7 @@ export function ProxmoxDashboard() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||
<HostBackup key={`backup-${componentKey}`} />
|
||||
<BackupTab key={`backup-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
"use client"
|
||||
|
||||
// The backup jobs of Proxmox for VMs and CTs (Datacenter > Backup).
|
||||
// Based on the contribution of MattiaC46 (PR #421).
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { ScrollArea } from "./ui/scroll-area"
|
||||
import {
|
||||
AlertTriangle, Archive, Calendar, CalendarClock, CheckCircle2, ChevronRight, DatabaseBackup, FileText, HardDrive,
|
||||
Layers, Loader2, Pencil, PlayCircle, Plus, Power, Server, Trash2,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface VmBackupJob {
|
||||
id: string
|
||||
enabled?: number | boolean | string
|
||||
schedule?: string
|
||||
storage?: string
|
||||
mode?: string
|
||||
compress?: string | number
|
||||
all?: number | boolean | string
|
||||
vmid?: string
|
||||
node?: string
|
||||
"prune-backups"?: string
|
||||
"notes-template"?: string
|
||||
host_backups?: string[]
|
||||
}
|
||||
|
||||
interface Guest { vmid: number; name: string; type: string; node?: string }
|
||||
interface Options { storages: { id: string; type: string }[]; guests: Guest[] }
|
||||
interface RunResult { tasks: { node: string; upid: string }[]; failures: { node: string; error: string }[] }
|
||||
|
||||
interface FormState {
|
||||
id?: string
|
||||
name: string
|
||||
allGuests: boolean
|
||||
vmids: string[]
|
||||
storage: string
|
||||
originalStorage: string
|
||||
schedule: string
|
||||
mode: string
|
||||
compress: string
|
||||
retention: Record<string, string>
|
||||
keepAll: boolean
|
||||
notes: string
|
||||
attached: string[]
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ key: "daily02", value: "02:00" },
|
||||
{ key: "daily03", value: "03:00" },
|
||||
{ key: "sunday02", value: "sun 02:00" },
|
||||
{ key: "every6h", value: "00/6:00" },
|
||||
{ key: "hourly", value: "hourly" },
|
||||
]
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: "", allGuests: true, vmids: [], storage: "", originalStorage: "", schedule: "02:00",
|
||||
mode: "snapshot", compress: "zstd", retention: {}, keepAll: false, notes: "{{guestname}}", attached: [],
|
||||
}
|
||||
|
||||
// The limits a job can keep, in the order Proxmox lists them.
|
||||
const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
const parseRetention = (prune: string) => {
|
||||
const fields: Record<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)
|
||||
const guestIds = (job: VmBackupJob) => String(job.vmid ?? "").split(",").map((id) => id.trim()).filter(Boolean)
|
||||
|
||||
const fetcher = async (url: string) => fetchApi<any>(url)
|
||||
|
||||
async function call(path: string, method: string, body?: unknown) {
|
||||
return fetchApi<any>(path, { method, body: body ? JSON.stringify(body) : undefined })
|
||||
}
|
||||
|
||||
export function VmBackupJobs() {
|
||||
const t = useT()
|
||||
const { data, error, mutate } = useSWR<{ jobs: VmBackupJob[] }>("/api/vm-backup-jobs", fetcher, { refreshInterval: 30000 })
|
||||
const { data: options } = useSWR<Options>("/api/vm-backup-jobs/options", fetcher, { refreshInterval: 60000 })
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState<VmBackupJob | null>(null)
|
||||
const [running, setRunning] = useState<VmBackupJob | null>(null)
|
||||
const [disabling, setDisabling] = useState<VmBackupJob | null>(null)
|
||||
const [viewingId, setViewingId] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
const jobs = data?.jobs ?? []
|
||||
const viewing = jobs.find((job) => job.id === viewingId) ?? null
|
||||
const guests = options?.guests ?? []
|
||||
const storages = options?.storages ?? []
|
||||
|
||||
const describeGuests = (job: VmBackupJob) => {
|
||||
if (isOn(job.all)) return t("backup.vmJobs.allGuests")
|
||||
const ids = guestIds(job)
|
||||
if (ids.length === 0) return t("backup.vmJobs.noGuests")
|
||||
return ids.map((id) => {
|
||||
const guest = guests.find((item) => String(item.vmid) === id)
|
||||
return guest ? `${id} (${guest.name})` : id
|
||||
}).join(", ")
|
||||
}
|
||||
|
||||
const openEdit = (job: VmBackupJob) => {
|
||||
setFormError(null)
|
||||
setForm({
|
||||
id: job.id,
|
||||
name: job.id,
|
||||
allGuests: isOn(job.all),
|
||||
vmids: guestIds(job),
|
||||
storage: job.storage ?? "",
|
||||
originalStorage: job.storage ?? "",
|
||||
schedule: job.schedule ?? "02:00",
|
||||
mode: job.mode ?? "snapshot",
|
||||
compress: String(job.compress ?? "zstd"),
|
||||
retention: parseRetention(job["prune-backups"] ?? "").fields,
|
||||
keepAll: parseRetention(job["prune-backups"] ?? "").keepAll,
|
||||
notes: job["notes-template"] ?? "",
|
||||
attached: job.host_backups ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
if (!form.storage) return setFormError(t("backup.vmJobs.needStorage"))
|
||||
if (!form.allGuests && form.vmids.length === 0) return setFormError(t("backup.vmJobs.needGuests"))
|
||||
setBusy("save")
|
||||
setFormError(null)
|
||||
const payload: Record<string, unknown> = {
|
||||
all: form.allGuests ? 1 : 0,
|
||||
vmid: form.allGuests ? "" : form.vmids.join(","),
|
||||
storage: form.storage,
|
||||
schedule: form.schedule.trim(),
|
||||
mode: form.mode,
|
||||
compress: form.compress,
|
||||
prune_backups: buildRetention(form.retention, form.keepAll),
|
||||
notes_template: form.notes.trim(),
|
||||
}
|
||||
if (!form.id && form.name.trim()) payload.id = form.name.trim()
|
||||
try {
|
||||
if (form.id) await call(`/api/vm-backup-jobs/${encodeURIComponent(form.id)}`, "PUT", payload)
|
||||
else await call("/api/vm-backup-jobs", "POST", payload)
|
||||
setMessage({ ok: true, text: form.id ? t("backup.vmJobs.updated") : t("backup.vmJobs.created") })
|
||||
setForm(null)
|
||||
mutate()
|
||||
} catch (problem) {
|
||||
setFormError(problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.saveFailed"))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (job: VmBackupJob) => {
|
||||
setBusy(job.id)
|
||||
try {
|
||||
const result = await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}/toggle`, "POST")
|
||||
setMessage({ ok: true, text: t(result?.enabled ? "backup.vmJobs.toggledOn" : "backup.vmJobs.toggledOff", { id: job.id }) })
|
||||
mutate()
|
||||
} catch (problem) {
|
||||
setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
setDisabling(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
if (!running) return
|
||||
const job = running
|
||||
setBusy(job.id)
|
||||
try {
|
||||
const result: RunResult = await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}/run`, "POST")
|
||||
let text = t("backup.vmJobs.started", { id: job.id, nodes: (result.tasks ?? []).map((task) => task.node).join(", ") })
|
||||
if (result.failures?.length) {
|
||||
text += " " + t("backup.vmJobs.startedPartial", { nodes: result.failures.map((failure) => failure.node).join(", ") })
|
||||
}
|
||||
setMessage({ ok: !result.failures?.length, text })
|
||||
} catch (problem) {
|
||||
setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
setRunning(null)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting) return
|
||||
const job = deleting
|
||||
setBusy(job.id)
|
||||
try {
|
||||
await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}`, "DELETE")
|
||||
setMessage({ ok: true, text: t("backup.vmJobs.deleted", { id: job.id }) })
|
||||
setViewingId(null)
|
||||
mutate()
|
||||
} catch (problem) {
|
||||
setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleGuest = (vmid: string) =>
|
||||
setForm((current) => current && ({
|
||||
...current,
|
||||
vmids: current.vmids.includes(vmid) ? current.vmids.filter((id) => id !== vmid) : [...current.vmids, vmid],
|
||||
}))
|
||||
|
||||
const preset = form && PRESETS.some((item) => item.value === form.schedule) ? form.schedule : "custom"
|
||||
const retentionOf = (job: VmBackupJob) => (job["prune-backups"] ?? "").split(",").map((part) => part.trim()).filter(Boolean)
|
||||
const compressionOf = (job: VmBackupJob) =>
|
||||
String(job.compress ?? "zstd") === "0" ? t("backup.vmJobs.compressNone") : String(job.compress ?? "zstd")
|
||||
|
||||
const retentionChips = (job: VmBackupJob) => {
|
||||
const parts = retentionOf(job)
|
||||
if (parts.length === 0) return <span className="text-muted-foreground">{t("backup.vmJobs.storageDefault")}</span>
|
||||
return parts.map((part) => {
|
||||
const [label, value] = part.split("=")
|
||||
const name = label.replace(/^keep-/, "")
|
||||
return (
|
||||
<span key={part} className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded border border-border bg-background/60">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{(RETENTION as readonly string[]).includes(name) ? t(`backup.retention.${name}`) : name}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-foreground">{value}</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const badges = (job: VmBackupJob) => (
|
||||
<>
|
||||
{!jobEnabled(job) && (
|
||||
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
{(job.host_backups ?? []).length > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5"
|
||||
title={t("backup.vmJobs.attachedHost", { ids: (job.host_backups ?? []).join(", ") })}
|
||||
>
|
||||
{t("backup.vmJobs.hostBackup")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CalendarClock className="h-5 w-5 text-blue-500 shrink-0" />
|
||||
<CardTitle className="text-base font-semibold truncate">{t("backup.vmJobs.title")}</CardTitle>
|
||||
<Badge variant="outline" className="ml-1">{jobs.length}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-3 bg-blue-500 hover:bg-blue-600 text-white shrink-0"
|
||||
onClick={() => { setFormError(null); setForm({ ...EMPTY_FORM, storage: storages[0]?.id ?? "" }) }}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("backup.vmJobs.newJob")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("backup.vmJobs.subtitle")}</p>
|
||||
|
||||
{message && !viewing && (
|
||||
<div
|
||||
role="status"
|
||||
className={`text-xs px-3 py-2 rounded-md border whitespace-pre-wrap break-words ${
|
||||
message.ok ? "text-green-500 border-green-500/30 bg-green-500/10" : "text-red-500 border-red-500/30 bg-red-500/10"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="text-sm text-red-500 py-4">{t("backup.vmJobs.loadFailed")}</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("backup.common.loading")}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4">{t("backup.vmJobs.empty")}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((job) => (
|
||||
<button
|
||||
key={job.id}
|
||||
type="button"
|
||||
onClick={() => { setMessage(null); setViewingId(job.id) }}
|
||||
className="w-full text-left flex items-start gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 transition-colors group"
|
||||
title={t("backup.jobs.openJobTitle")}
|
||||
>
|
||||
<div className="min-w-0 flex-1 w-full">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-base truncate" title={job.id}>{job.id}</span>
|
||||
{badges(job)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground line-clamp-2 break-words">{describeGuests(job)}</div>
|
||||
<div className="mt-2 text-xs text-foreground flex items-center gap-x-4 gap-y-1 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1" title={t("backup.vmJobs.labelSchedule")}>
|
||||
<Calendar className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="font-mono">{job.schedule || "—"}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1" title={t("backup.vmJobs.labelStorage")}>
|
||||
<HardDrive className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>{job.storage || "—"}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1" title={t("backup.vmJobs.labelMode")}>
|
||||
<Layers className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>{job.mode ?? "snapshot"} · {compressionOf(job)}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 flex-wrap" title={t("backup.vmJobs.labelRetention")}>
|
||||
<Archive className="h-3.5 w-3.5 text-green-500" />
|
||||
{retentionChips(job)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-blue-400 transition-colors shrink-0 mt-1" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Job detail */}
|
||||
<Dialog open={!!viewing} onOpenChange={(open) => { if (!open) { setViewingId(null); setMessage(null) } }}>
|
||||
<DialogContent className="max-w-3xl bg-card border-border overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 flex-wrap text-base pr-8">
|
||||
<DatabaseBackup className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-mono break-all">{viewing?.id}</span>
|
||||
{viewing && badges(viewing)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.subtitle")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
role="status"
|
||||
className={`text-xs px-3 py-2 rounded-md border whitespace-pre-wrap break-words ${
|
||||
message.ok ? "text-green-500 border-green-500/30 bg-green-500/10" : "text-red-500 border-red-500/30 bg-red-500/10"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<ScrollArea className="max-h-[60vh] pr-2">
|
||||
<div className="space-y-4 text-sm">
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-emerald-400">
|
||||
<Server className="h-3.5 w-3.5" /> {t("backup.vmJobs.fieldGuests")}
|
||||
</h4>
|
||||
<div className="break-words">{describeGuests(viewing)}</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-amber-400">
|
||||
<Calendar className="h-3.5 w-3.5" /> {t("backup.vmJobs.labelSchedule")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.labelSchedule")}</div>
|
||||
<div className="font-mono break-all">{viewing.schedule || "—"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.labelRetention")}</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">{retentionChips(viewing)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-blue-400">
|
||||
<HardDrive className="h-3.5 w-3.5" /> {t("backup.vmJobs.labelStorage")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.labelStorage")}</div>
|
||||
<div className="break-words">{viewing.storage || "—"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.labelMode")}</div>
|
||||
<div>{viewing.mode ?? "snapshot"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.fieldCompress")}</div>
|
||||
<div>{compressionOf(viewing)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{viewing["notes-template"] && (
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" /> {t("backup.vmJobs.fieldNotes")}
|
||||
</h4>
|
||||
<div className="font-mono break-all">{viewing["notes-template"]}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(viewing.host_backups ?? []).length > 0 && (
|
||||
<div className="rounded-md border border-blue-500/40 bg-blue-500/5 p-2 text-xs">
|
||||
{t("backup.vmJobs.attachedHost", { ids: (viewing.host_backups ?? []).join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<div className="border-t border-border pt-3 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy !== null}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={() => setRunning(viewing)}
|
||||
>
|
||||
{busy === viewing.id && running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{t("backup.actions.runNow")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
className="bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
|
||||
onClick={() => { const job = viewing; setViewingId(null); setMessage(null); openEdit(job) }}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1" />
|
||||
{t("backup.actions.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
className={jobEnabled(viewing)
|
||||
? "bg-amber-500/10 border-amber-500/40 !text-amber-400 hover:bg-amber-500/20 hover:!text-amber-300"
|
||||
: "bg-emerald-500/10 border-emerald-500/40 !text-emerald-400 hover:bg-emerald-500/20 hover:!text-emerald-300"}
|
||||
onClick={() => (jobEnabled(viewing) ? setDisabling(viewing) : toggle(viewing))}
|
||||
>
|
||||
{busy === viewing.id && !running && !deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : jobEnabled(viewing) ? (
|
||||
<Power className="h-3.5 w-3.5 mr-1" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{jobEnabled(viewing) ? t("backup.actions.disable") : t("backup.actions.enable")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
className="bg-red-500/10 border-red-500/40 !text-red-400 hover:bg-red-500/20 hover:!text-red-300"
|
||||
onClick={() => setDeleting(viewing)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
{t("backup.actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create / edit: edit mode, the editable fields sunken */}
|
||||
<Dialog open={!!form} onOpenChange={(open) => !open && setForm(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarClock className="h-5 w-5 text-blue-500" />
|
||||
{form?.id ? t("backup.vmJobs.dialogEdit", { id: form.id }) : t("backup.vmJobs.dialogNew")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.subtitle")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{form && (
|
||||
<div className="flex-1 overflow-y-auto pr-1 space-y-4 text-sm">
|
||||
{!form.id && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vm-job-name">{t("backup.vmJobs.fieldName")}</Label>
|
||||
<Input id="vm-job-name" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<p className="text-xs text-muted-foreground">{t("backup.vmJobs.fieldNameHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("backup.vmJobs.fieldGuests")}</Label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={form.allGuests} onCheckedChange={(checked) => setForm({ ...form, allGuests: checked === true })} />
|
||||
{t("backup.vmJobs.fieldAll")}
|
||||
</label>
|
||||
{!form.allGuests && (
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border border-border bg-background p-2 grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||
{guests.map((guest) => (
|
||||
<label key={guest.vmid} className="flex items-center gap-2 min-w-0">
|
||||
<Checkbox checked={form.vmids.includes(String(guest.vmid))} onCheckedChange={() => toggleGuest(String(guest.vmid))} />
|
||||
<span className="truncate">{guest.vmid} · {guest.name}</span>
|
||||
<span className="text-muted-foreground shrink-0">({guest.type === "qemu" ? "VM" : "LXC"})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>{t("backup.vmJobs.fieldStorage")}</Label>
|
||||
{storages.length === 0 ? (
|
||||
<p className="text-xs text-amber-500">{t("backup.vmJobs.noStorage")}</p>
|
||||
) : (
|
||||
<Select value={form.storage} onValueChange={(value) => setForm({ ...form, storage: value })}>
|
||||
<SelectTrigger><SelectValue placeholder={t("backup.vmJobs.selectStorage")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{storages.map((storage) => (
|
||||
<SelectItem key={storage.id} value={storage.id}>{storage.id} ({storage.type})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{form.attached.length > 0 && form.storage !== form.originalStorage && (
|
||||
<p className="text-xs text-amber-500 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{t("backup.vmJobs.storageAttached", { ids: form.attached.join(", ") })}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("backup.vmJobs.fieldSchedule")}</Label>
|
||||
<Select value={preset} onValueChange={(value) => value !== "custom" && setForm({ ...form, schedule: value })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESETS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>{t(`backup.vmJobs.presets.${item.key}`)}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">{t("backup.vmJobs.scheduleCustom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vm-job-schedule">{t("backup.vmJobs.scheduleCustom")}</Label>
|
||||
<Input
|
||||
id="vm-job-schedule"
|
||||
className="font-mono"
|
||||
value={form.schedule}
|
||||
onChange={(event) => setForm({ ...form, schedule: event.target.value })}
|
||||
placeholder={t("backup.vmJobs.schedulePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("backup.vmJobs.fieldMode")}</Label>
|
||||
<Select value={form.mode} onValueChange={(value) => setForm({ ...form, mode: value })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="snapshot">{t("backup.vmJobs.modeSnapshot")}</SelectItem>
|
||||
<SelectItem value="suspend">{t("backup.vmJobs.modeSuspend")}</SelectItem>
|
||||
<SelectItem value="stop">{t("backup.vmJobs.modeStop")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("backup.vmJobs.fieldCompress")}</Label>
|
||||
<Select value={form.compress} onValueChange={(value) => setForm({ ...form, compress: value })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zstd">zstd</SelectItem>
|
||||
<SelectItem value="gzip">gzip</SelectItem>
|
||||
<SelectItem value="lzo">lzo</SelectItem>
|
||||
<SelectItem value="0">{t("backup.vmJobs.compressNone")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t("backup.retention.title")}</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t("backup.retention.zeroDisables")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{RETENTION.map((key) => (
|
||||
<div key={key}>
|
||||
<Label htmlFor={`vm-job-keep-${key}`} className="text-xs">
|
||||
{t(`backup.retention.keep${key[0].toUpperCase()}${key.slice(1)}`)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`vm-job-keep-${key}`}
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.retention[key] ?? ""}
|
||||
onChange={(event) => setForm({ ...form, retention: { ...form.retention, [key]: event.target.value } })}
|
||||
className="font-mono mt-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vm-job-notes">{t("backup.vmJobs.fieldNotes")}</Label>
|
||||
<Input id="vm-job-notes" value={form.notes} onChange={(event) => setForm({ ...form, notes: event.target.value })} />
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cancelButtonClass}
|
||||
onClick={() => setForm(null)}
|
||||
disabled={busy === "save"}
|
||||
>
|
||||
{t("backup.vmJobs.cancel")}
|
||||
</Button>
|
||||
<Button className="bg-blue-500 hover:bg-blue-600 text-white" disabled={busy === "save"} onClick={save}>
|
||||
{busy === "save" && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
{busy === "save" ? t("backup.vmJobs.saving") : t("backup.vmJobs.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Run now */}
|
||||
<Dialog open={!!running} onOpenChange={(open) => !open && setRunning(null)}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5 text-green-500" />
|
||||
{t("backup.vmJobs.runTitle", { id: running?.id ?? "" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.runBody")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{running?.mode === "stop" && (
|
||||
<p className="text-xs text-amber-500 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{t("backup.vmJobs.runStop")}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setRunning(null)}>{t("backup.vmJobs.cancel")}</Button>
|
||||
<Button className="bg-green-600 hover:bg-green-700 text-white" disabled={busy !== null} onClick={runNow}>
|
||||
{t("backup.actions.runNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Disable */}
|
||||
<Dialog open={!!disabling} onOpenChange={(open) => !open && setDisabling(null)}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
<Power className="h-5 w-5 text-amber-500" />
|
||||
{t("backup.vmJobs.disableTitle", { id: disabling?.id ?? "" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.disableBody")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setDisabling(null)}>{t("backup.vmJobs.cancel")}</Button>
|
||||
<Button
|
||||
className="bg-amber-500/10 border border-amber-500/40 !text-amber-400 hover:bg-amber-500/20 hover:!text-amber-300"
|
||||
disabled={busy !== null}
|
||||
onClick={() => disabling && toggle(disabling)}
|
||||
>
|
||||
{t("backup.actions.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete */}
|
||||
<Dialog open={!!deleting} onOpenChange={(open) => !open && setDeleting(null)}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
{t("backup.vmJobs.deleteTitle", { id: deleting?.id ?? "" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.deleteBody")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(deleting?.host_backups?.length ?? 0) > 0 && (
|
||||
<p className="text-xs text-amber-500 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{t("backup.vmJobs.deleteAttached", { ids: (deleting?.host_backups ?? []).join(", ") })}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setDeleting(null)}>{t("backup.vmJobs.cancel")}</Button>
|
||||
<Button className="bg-red-600 hover:bg-red-700 text-white" disabled={busy !== null} onClick={confirmDelete}>
|
||||
{t("backup.vmJobs.deleteConfirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -36,3 +36,7 @@ export function formatBytes(n: number): string {
|
||||
}
|
||||
return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`
|
||||
}
|
||||
|
||||
// Cancel and Close in a dialog: an outlined button that reads as a button
|
||||
// on every dialog background.
|
||||
export const cancelButtonClass = "bg-zinc-800/50 border-zinc-700 text-zinc-300 hover:bg-zinc-700/50"
|
||||
|
||||
@@ -4603,6 +4603,77 @@
|
||||
"validation": {
|
||||
"invalidLabel": "Ungültiges Etikett."
|
||||
},
|
||||
"viewSwitch": {
|
||||
"ariaLabel": "Zwischen Host-Backups und Backups von VMs und LXCs wechseln",
|
||||
"host": "Host",
|
||||
"guests": "VMs und LXCs"
|
||||
},
|
||||
"vmJobs": {
|
||||
"title": "Backup-Jobs für VMs und CTs",
|
||||
"subtitle": "Die geplanten Backup-Jobs von Proxmox, dieselben wie unter Datacenter → Backup.",
|
||||
"newJob": "Neuer Job",
|
||||
"loadFailed": "Die Backup-Jobs konnten nicht gelesen werden",
|
||||
"empty": "Noch kein Backup-Job. Erstellen Sie den ersten mit „Neuer Job“.",
|
||||
"allGuests": "Alle VMs und CTs",
|
||||
"noGuests": "Keine VM oder CT ausgewählt",
|
||||
"storageDefault": "Standard des Speichers",
|
||||
"hostBackup": "Host-Backup",
|
||||
"attachedHost": "Host-Backups, die mit diesem Job laufen: {ids}",
|
||||
"created": "Job erstellt",
|
||||
"updated": "Job aktualisiert",
|
||||
"deleted": "Job {id} gelöscht",
|
||||
"toggledOn": "Job {id} aktiviert",
|
||||
"toggledOff": "Job {id} deaktiviert",
|
||||
"started": "Backup von {id} als Proxmox-Task gestartet auf: {nodes}.",
|
||||
"startedPartial": "Konnte nicht gestartet werden auf: {nodes}.",
|
||||
"dialogNew": "Neuer Backup-Job",
|
||||
"dialogEdit": "Job {id} bearbeiten",
|
||||
"fieldName": "Name (optional)",
|
||||
"fieldNameHint": "Buchstaben, Ziffern und Bindestriche. Leer: Proxmox vergibt einen.",
|
||||
"fieldGuests": "Zu sichernde Gäste",
|
||||
"fieldAll": "Alle VMs und Container",
|
||||
"fieldStorage": "Zielspeicher",
|
||||
"selectStorage": "Auswählen…",
|
||||
"noStorage": "Kein Speicher nimmt Backups an. Fügen Sie einen in Proxmox unter Datacenter → Storage hinzu.",
|
||||
"fieldSchedule": "Zeitplan",
|
||||
"scheduleCustom": "Benutzerdefiniert",
|
||||
"schedulePlaceholder": "z. B. mon..fri 01:30",
|
||||
"fieldMode": "Modus",
|
||||
"modeSnapshot": "Snapshot (ohne Unterbrechung)",
|
||||
"modeSuspend": "Suspend (pausiert den Gast)",
|
||||
"modeStop": "Stop (fährt den Gast herunter)",
|
||||
"fieldCompress": "Komprimierung",
|
||||
"compressNone": "Keine",
|
||||
"fieldNotes": "Backup-Notizen",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern…",
|
||||
"needStorage": "Wählen Sie den Zielspeicher",
|
||||
"needGuests": "Wählen Sie mindestens eine VM oder einen CT",
|
||||
"saveFailed": "Der Job konnte nicht gespeichert werden",
|
||||
"actionFailed": "Der Vorgang ist fehlgeschlagen",
|
||||
"deleteTitle": "Job {id} löschen",
|
||||
"deleteBody": "Nur der Zeitplan wird entfernt. Die bereits erstellten Backups bleiben im Speicher.",
|
||||
"deleteAttached": "Diese Host-Backups laufen mit diesem Job und werden nicht mehr ausgeführt: {ids}",
|
||||
"storageAttached": "Diese Host-Backups laufen mit diesem Job und erwarten dessen Speicher: {ids}. Mit einem anderen Speicher werden sie nicht mehr ausgeführt.",
|
||||
"deleteConfirm": "Job löschen",
|
||||
"disableTitle": "Job {id} deaktivieren",
|
||||
"disableBody": "Der Job bleibt konfiguriert, läuft aber erst wieder, wenn er aktiviert wird.",
|
||||
"runTitle": "Job {id} jetzt ausführen",
|
||||
"runBody": "Das Backup startet jetzt mit den Einstellungen des Jobs, als Proxmox-Task.",
|
||||
"runStop": "Der Modus dieses Jobs ist stop: seine Gäste werden während des Backups heruntergefahren.",
|
||||
"labelSchedule": "Zeitplan",
|
||||
"labelStorage": "Speicher",
|
||||
"labelMode": "Modus",
|
||||
"labelRetention": "Aufbewahrung",
|
||||
"presets": {
|
||||
"daily02": "Täglich um 02:00",
|
||||
"daily03": "Täglich um 03:00",
|
||||
"sunday02": "Jeden Sonntag um 02:00",
|
||||
"every6h": "Alle 6 Stunden",
|
||||
"hourly": "Stündlich"
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"friday": "Freitag",
|
||||
"monday": "Montag",
|
||||
|
||||
@@ -4602,6 +4602,77 @@
|
||||
"validation": {
|
||||
"invalidLabel": "Invalid label."
|
||||
},
|
||||
"viewSwitch": {
|
||||
"ariaLabel": "Switch between host backups and VM and LXC backups",
|
||||
"host": "Host",
|
||||
"guests": "VMs and LXCs"
|
||||
},
|
||||
"vmJobs": {
|
||||
"title": "VM and CT backup jobs",
|
||||
"subtitle": "The scheduled backup jobs of Proxmox, the same ones as Datacenter → Backup.",
|
||||
"newJob": "New job",
|
||||
"loadFailed": "Could not read the backup jobs",
|
||||
"empty": "No backup job yet. Create the first one with “New job”.",
|
||||
"allGuests": "All VMs and CTs",
|
||||
"noGuests": "No guest selected",
|
||||
"storageDefault": "storage default",
|
||||
"hostBackup": "Host backup",
|
||||
"attachedHost": "Host backups that run with this job: {ids}",
|
||||
"created": "Job created",
|
||||
"updated": "Job updated",
|
||||
"deleted": "Job {id} deleted",
|
||||
"toggledOn": "Job {id} enabled",
|
||||
"toggledOff": "Job {id} disabled",
|
||||
"started": "Backup of {id} started as a Proxmox task on: {nodes}.",
|
||||
"startedPartial": "It could not be started on: {nodes}.",
|
||||
"dialogNew": "New backup job",
|
||||
"dialogEdit": "Edit job {id}",
|
||||
"fieldName": "Name (optional)",
|
||||
"fieldNameHint": "Letters, numbers and hyphens. Empty: Proxmox generates one.",
|
||||
"fieldGuests": "Guests to back up",
|
||||
"fieldAll": "All VMs and containers",
|
||||
"fieldStorage": "Destination storage",
|
||||
"selectStorage": "Select…",
|
||||
"noStorage": "No storage accepts backups. Add one in Proxmox, Datacenter → Storage.",
|
||||
"fieldSchedule": "Schedule",
|
||||
"scheduleCustom": "Custom",
|
||||
"schedulePlaceholder": "e.g. mon..fri 01:30",
|
||||
"fieldMode": "Mode",
|
||||
"modeSnapshot": "Snapshot (no downtime)",
|
||||
"modeSuspend": "Suspend (pauses the guest)",
|
||||
"modeStop": "Stop (shuts the guest down)",
|
||||
"fieldCompress": "Compression",
|
||||
"compressNone": "None",
|
||||
"fieldNotes": "Backup notes",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"needStorage": "Choose the destination storage",
|
||||
"needGuests": "Select at least one VM or CT",
|
||||
"saveFailed": "The job could not be saved",
|
||||
"actionFailed": "The operation failed",
|
||||
"deleteTitle": "Delete job {id}",
|
||||
"deleteBody": "Only the schedule is removed. The backups already made stay in the storage.",
|
||||
"deleteAttached": "These host backups run with this job and will stop running: {ids}",
|
||||
"storageAttached": "These host backups run with this job and expect its storage: {ids}. With another storage they stop running.",
|
||||
"deleteConfirm": "Delete job",
|
||||
"disableTitle": "Disable job {id}",
|
||||
"disableBody": "The job stays configured but does not run until it is enabled again.",
|
||||
"runTitle": "Run job {id} now",
|
||||
"runBody": "The backup starts now with the settings of the job, as a Proxmox task.",
|
||||
"runStop": "The mode of this job is stop: its guests are shut down during the backup.",
|
||||
"labelSchedule": "Schedule",
|
||||
"labelStorage": "Storage",
|
||||
"labelMode": "Mode",
|
||||
"labelRetention": "Retention",
|
||||
"presets": {
|
||||
"daily02": "Every day at 02:00",
|
||||
"daily03": "Every day at 03:00",
|
||||
"sunday02": "Every Sunday at 02:00",
|
||||
"every6h": "Every 6 hours",
|
||||
"hourly": "Every hour"
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"friday": "Friday",
|
||||
"monday": "Monday",
|
||||
|
||||
+218
-147
@@ -364,15 +364,15 @@
|
||||
"passedUpper": "CORRECTO",
|
||||
"testAgeWarning": "Este informe se basa en una prueba SMART realizada hace {days} días ({date}). Es posible que la salud del disco haya cambiado desde entonces. Recomendamos ejecutar una nueva prueba SMART para obtener resultados actualizados.",
|
||||
"healthyAssessment": "Este disco está funcionando dentro de los parámetros normales. Todos los atributos SMART están dentro de umbrales aceptables. El disco ha estado encendido durante aproximadamente {uptime} y actualmente está funcionando a {temperature}. {sectors}",
|
||||
"failedAssessment": "Este disco ha informado de un error de salud SMART. Se requiere acción inmediata. Haga una copia de seguridad de todos los datos críticos y planifique el reemplazo del disco.",
|
||||
"failedAssessment": "Este disco ha informado de un error de salud SMART. Se requiere acción inmediata. Haga backup de todos los datos críticos y planifique el reemplazo del disco.",
|
||||
"noBadSectors": "No se han detectado sectores defectuosos.",
|
||||
"reallocatedSectors": "{count} sectores reasignados detectados: supervise de cerca.",
|
||||
"healthyMeaningTitle": "¿Qué quiere decir esto? ¡Tu disco está sano!",
|
||||
"attentionTitle": "ATENCIÓN REQUERIDA: Problemas detectados",
|
||||
"monitorTitle": "Algunas cuestiones necesitan seguimiento",
|
||||
"healthyMeaning": "En términos simples: este disco funciona correctamente. Puedes seguir usándolo normalmente. Recomendamos realizar pruebas SMART periódicas (mensuales) para detectar cualquier problema a tiempo.",
|
||||
"criticalMeaning": "En términos simples: este disco tiene problemas que podrían provocar la pérdida de datos. Haga una copia de seguridad de sus archivos importantes de inmediato y considere reemplazar el disco pronto.",
|
||||
"warningMeaning": "En términos simples: el disco funciona pero muestra algunos signos de desgaste. Aún no es crítico, pero vigílelo de cerca y mantenga sus copias de seguridad actualizadas.",
|
||||
"criticalMeaning": "En términos simples: este disco tiene problemas que podrían provocar la pérdida de datos. Haga backup de sus archivos importantes de inmediato y considere reemplazar el disco pronto.",
|
||||
"warningMeaning": "En términos simples: el disco funciona pero muestra algunos signos de desgaste. Aún no es crítico, pero vigílelo de cerca y mantenga sus backups actualizados.",
|
||||
"issuesFound": "Problemas encontrados",
|
||||
"criticalStatus": "Crítico: requiere atención inmediata",
|
||||
"warningStatus": "Advertencia: debe ser monitoreado",
|
||||
@@ -722,7 +722,7 @@
|
||||
"healthyTitle": "El disco está sano",
|
||||
"healthyText": "Todos los atributos SMART están dentro de los rangos normales. Continuar con el seguimiento periódico.",
|
||||
"criticalTitle": "Crítico: problema de salud del disco detectado",
|
||||
"criticalText": "SMART ha informado de un problema de salud. Haga una copia de seguridad de todos los datos inmediatamente y planifique el reemplazo del disco.",
|
||||
"criticalText": "SMART ha informado de un problema de salud. Haga backup de todos los datos inmediatamente y planifique el reemplazo del disco.",
|
||||
"reallocatedTitle": "Sectores reasignados detectados ({count})",
|
||||
"reallocatedText": "El disco tiene sectores defectuosos que han sido reasignados. Vigílelo de cerca y considere reemplazarlo si el recuento aumenta.",
|
||||
"pendingTitle": "Sectores Pendientes ({count})",
|
||||
@@ -733,25 +733,25 @@
|
||||
"hotNvme": "El NVMe se está sobrecalentando. Considere agregar un disipador de calor o mejorar el flujo de aire de la carcasa.",
|
||||
"maintenanceTitle": "Mantenimiento regular",
|
||||
"maintenanceText": "Programe una prueba SMART ampliada cada mes para detectar problemas con antelación.",
|
||||
"backupTitle": "Estrategia de respaldo",
|
||||
"backupText": "Realice copias de seguridad de los datos críticos con regularidad, independientemente del estado del disco.",
|
||||
"backupTitle": "Estrategia de backup",
|
||||
"backupText": "Haga backups de los datos críticos con regularidad, independientemente del estado del disco.",
|
||||
"nvmeWarningTitle": "Advertencia crítica de NVMe activa (0x{value})",
|
||||
"nvmeWarningText": "El controlador NVMe ha generado una alerta. Haga una copia de seguridad de los datos inmediatamente e investigue más a fondo.",
|
||||
"nvmeWarningText": "El controlador NVMe ha generado una alerta. Haga backup de los datos inmediatamente e investigue más a fondo.",
|
||||
"nvmeMediaTitle": "Errores de medios NVMe detectados ({count})",
|
||||
"nvmeMediaText": "Los errores de flash NAND irrecuperables indican daño físico. Haga una copia de seguridad de los datos y planifique el reemplazo.",
|
||||
"nvmeMediaText": "Los errores de flash NAND irrecuperables indican daño físico. Haga backup de los datos y planifique el reemplazo.",
|
||||
"unsafeTitle": "Recuento elevado de apagados inseguros ({count})",
|
||||
"unsafeText": "Las frecuentes pérdidas de energía aumentan el riesgo de corrupción del firmware. Asegure una energía estable o utilice un UPS.",
|
||||
"seagateTitle": "Seagate Raw_Read_Error_Rate: comportamiento normal",
|
||||
"seagateText": "Las unidades Seagate informan valores brutos muy grandes para el atributo n.º 1. Esto es de esperar; sólo importa el valor normalizado y debe permanecer en 100.",
|
||||
"smrTitle": "Unidad SMR detectada: limitaciones de escritura",
|
||||
"smrText": "Los discos SMR escriben datos aleatorios más lentamente y pueden detenerse bajo una carga de trabajo mixta intensa. Se adaptan a copias de seguridad y archivos, pero no se recomiendan como almacenamiento principal de Proxmox o vdevs ZFS.",
|
||||
"smrText": "Los discos SMR escriben datos aleatorios más lentamente y pueden detenerse bajo una carga de trabajo mixta intensa. Se adaptan a backups y archivos, pero no se recomiendan como almacenamiento principal de Proxmox o vdevs ZFS.",
|
||||
"passedTitle": "revisar los resultados informados",
|
||||
"passedText": "Un estado PASADO puede coexistir con advertencias o errores registrados.Revise los detalles del informe, continúe con el monitoreo regular y mantenga copias de seguridad actualizadas."
|
||||
"passedText": "Un estado PASADO puede coexistir con advertencias o errores registrados. Revise los detalles del informe, continúe con el monitoreo regular y mantenga backups actualizados."
|
||||
},
|
||||
"passedAssessment": "El estado SMART proporcionado es APROBADO.Este estado por sí solo no establece que todos los atributos estén dentro del rango o que el disco esté libre de fallas.Hora de encendido mostrada: {uptime}.Temperatura mostrada: {temperature}.{sectors}",
|
||||
"noReallocatedSectorsReported": "el valor del sector reasignado proporcionado es cero o no está disponible;esto no establece que no haya sectores malos.",
|
||||
"passedMeaningTitle": "¿Qué significa un estado APROBADO?",
|
||||
"passedMeaning": "PASADO es el estado general proporcionado en este informe, no una prueba de una autoprueba completa ni una garantía contra fallas.Revise los atributos, los registros de errores y los resultados de las autopruebas, y mantenga copias de seguridad actualizadas."
|
||||
"passedMeaning": "PASADO es el estado general proporcionado en este informe, no una prueba de una autoprueba completa ni una garantía contra fallas. Revise los atributos, los registros de errores y los resultados de las autopruebas, y mantenga backups actualizados."
|
||||
},
|
||||
"ataErrors": {
|
||||
"idnf": "Dirección del sector no encontrada: posible sector defectuoso o problema con el cable",
|
||||
@@ -1093,12 +1093,12 @@
|
||||
"containerUi": "Contenedor"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Copias de seguridad",
|
||||
"create": "Crear copia de seguridad",
|
||||
"available": "Copias de seguridad disponibles",
|
||||
"loading": "Cargando copias de seguridad...",
|
||||
"empty": "No se encontraron copias de seguridad",
|
||||
"emptyHint": "Crea tu primera copia de seguridad usando el botón de arriba"
|
||||
"title": "Backups",
|
||||
"create": "Crear backup",
|
||||
"available": "Backups disponibles",
|
||||
"loading": "Cargando backups...",
|
||||
"empty": "No se encontraron backups",
|
||||
"emptyHint": "Crea tu primer backup con el botón de arriba"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Actualizaciones de paquetes pendientes",
|
||||
@@ -1119,7 +1119,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"unknown": "Error desconocido",
|
||||
"backupStartFailed": "No se pudo iniciar la copia de seguridad: {message}",
|
||||
"backupStartFailed": "No se pudo iniciar el backup: {message}",
|
||||
"controlFailed": "Error en {action} VM {vmid}: {message}",
|
||||
"saveNotesFailed": "Error al guardar notas. Por favor inténtalo de nuevo.",
|
||||
"appNotFound": "Esta aplicación ya no está disponible. Actualiza la página y vuelve a intentarlo.",
|
||||
@@ -1129,8 +1129,8 @@
|
||||
"hideNoticeFailed": "No se pudo ocultar este aviso: {message}"
|
||||
},
|
||||
"backupModal": {
|
||||
"title": "Copia de seguridad {type} {vmid} ({name})",
|
||||
"description": "Configure las opciones de copia de seguridad para este {type}",
|
||||
"title": "Backup de {type} {vmid} ({name})",
|
||||
"description": "Configure las opciones de backup para este {type}",
|
||||
"storage": "Almacenamiento",
|
||||
"selectStorage": "Seleccionar almacenamiento",
|
||||
"free": "libre",
|
||||
@@ -1273,7 +1273,7 @@
|
||||
"noTargets": "Ningún objetivo seleccionado",
|
||||
"selectAtLeastOne": "Seleccione al menos un objetivo de actualización.",
|
||||
"deleteButton": "Eliminar horario",
|
||||
"deleteConfirm": "¿Eliminar las actualizaciones programadas para este contenedor? Se mantienen los valores predeterminados de aplicación (copia de seguridad + reinicio).",
|
||||
"deleteConfirm": "¿Eliminar las actualizaciones programadas de este contenedor? Se conservan los valores predeterminados de aplicación (backup + reinicio).",
|
||||
"loadFailed": "No se pudieron cargar las actualizaciones programadas.",
|
||||
"saveFailed": "no se pudieron guardar las actualizaciones programadas.",
|
||||
"deleteFailed": "no se pudieron eliminar las actualizaciones programadas.",
|
||||
@@ -1843,7 +1843,7 @@
|
||||
"description": "Configure canales de notificación y filtros de eventos. Recibe alertas vía Telegram, Gotify, Discord o Email.",
|
||||
"disabledDescription": "Reciba alertas en tiempo real sobre su entorno Proxmox a través de Telegram, Discord, Gotify o correo electrónico.",
|
||||
"enableService": "Habilitar el servicio de notificaciones",
|
||||
"enableServiceDescription": "Supervise el estado del sistema, los eventos de VM/CT, las copias de seguridad, las alertas de seguridad y el estado del clúster. La integración del webhook PVE se configura automáticamente.",
|
||||
"enableServiceDescription": "Supervise el estado del sistema, los eventos de VM/CT, los backups, las alertas de seguridad y el estado del clúster. La integración del webhook PVE se configura automáticamente.",
|
||||
"enableButton": "Habilitar notificaciones",
|
||||
"configuring": "Configurando...",
|
||||
"webhookConfigured": "Webhook PVE configurado automáticamente. Proxmox enviará notificaciones a ProxMenux.",
|
||||
@@ -1870,7 +1870,7 @@
|
||||
"label": "VM / TC"
|
||||
},
|
||||
"backup": {
|
||||
"label": "Copias de seguridad"
|
||||
"label": "Backups"
|
||||
},
|
||||
"resources": {
|
||||
"label": "Recursos"
|
||||
@@ -1931,13 +1931,13 @@
|
||||
"migration_fail": "La migración falló",
|
||||
"replication_fail": "La replicación falló",
|
||||
"replication_complete": "Replicación completa",
|
||||
"backup_start": "Copia de seguridad iniciada",
|
||||
"backup_complete": "Copia de seguridad completa",
|
||||
"backup_warning": "Copia de seguridad completada con advertencias",
|
||||
"backup_fail": "Error en la copia de seguridad",
|
||||
"host_backup_start": "Se inició la copia de seguridad del host",
|
||||
"host_backup_complete": "Copia de seguridad del host completa",
|
||||
"host_backup_fail": "Error en la copia de seguridad del host",
|
||||
"backup_start": "Backup iniciado",
|
||||
"backup_complete": "Backup completado",
|
||||
"backup_warning": "Backup completado con advertencias",
|
||||
"backup_fail": "Backup fallido",
|
||||
"host_backup_start": "Backup del host iniciado",
|
||||
"host_backup_complete": "Backup del host completado",
|
||||
"host_backup_fail": "Backup del host fallido",
|
||||
"snapshot_complete": "Instantánea creada",
|
||||
"snapshot_fail": "La instantánea falló",
|
||||
"cpu_high": "Alto uso de CPU",
|
||||
@@ -2257,7 +2257,7 @@
|
||||
},
|
||||
"zfs_pool": {
|
||||
"title": "Capacidad del grupo ZFS",
|
||||
"description": "Grupos ZFS a nivel de host, independientes del registro PVE, por lo que también se monitorean el rpool y los grupos de respaldo dedicados."
|
||||
"description": "Pools ZFS a nivel de host, independientes del registro en PVE, por lo que también se monitorizan rpool y los pools dedicados a backup."
|
||||
}
|
||||
},
|
||||
"swapPressure": "Presión de memoria",
|
||||
@@ -2303,7 +2303,7 @@
|
||||
"twoFactorTitle": "Autenticación de dos factores",
|
||||
"twoFactorDescription": "Ingrese el código de 6 dígitos de su aplicación de autenticación",
|
||||
"authenticationCode": "Código de autenticación",
|
||||
"backupCodeHint": "También puedes utilizar un código de respaldo (formato: XXXX-XXXX)",
|
||||
"backupCodeHint": "También puedes utilizar un código de backup (formato: XXXX-XXXX)",
|
||||
"backToLogin": "Volver a iniciar sesión",
|
||||
"verifyCode": "Verificar código"
|
||||
},
|
||||
@@ -2416,7 +2416,7 @@
|
||||
"changePasswordFailed": "No se pudo cambiar la contraseña",
|
||||
"invalidPassword": "La contraseña no es correcta.",
|
||||
"enterPassword": "Ingrese su contraseña.",
|
||||
"enter2faOrBackup": "Ingrese un código 2FA o un código de respaldo.",
|
||||
"enter2faOrBackup": "Ingrese un código 2FA o un código de backup.",
|
||||
"disable2faFailed": "No se pudo desactivar 2FA",
|
||||
"invalid2faCode": "El código 2FA no es correcto.",
|
||||
"enter2fa": "Ingrese un código 2FA.",
|
||||
@@ -2500,8 +2500,8 @@
|
||||
"enabled": "2FA está habilitado.",
|
||||
"disable": "Deshabilitar 2FA",
|
||||
"disableTitle": "Deshabilitar la autenticación de dos factores",
|
||||
"disableDescription": "Confirme con su contraseña y un código 2FA actual o un código de respaldo.",
|
||||
"codeOrBackup": "Código 2FA o código de respaldo",
|
||||
"disableDescription": "Confirme con su contraseña y un código 2FA actual o un código de backup.",
|
||||
"codeOrBackup": "Código 2FA o código de backup",
|
||||
"codeOrBackupPlaceholder": "123456 o XXXX-XXXX",
|
||||
"disabling": "Desactivando...",
|
||||
"code": "código 2FA",
|
||||
@@ -2931,7 +2931,7 @@
|
||||
"youWillNeed": "Necesitarás:",
|
||||
"needApp": "Una aplicación de autenticación como Aegis, 1Password, Bitwarden o Google Authenticator.",
|
||||
"needQrOrKey": "Un código QR o clave manual de esta configuración.",
|
||||
"needBackupCodes": "Códigos de respaldo guardados en algún lugar seguro.",
|
||||
"needBackupCodes": "Códigos de backup guardados en algún lugar seguro.",
|
||||
"starting": "A partir de...",
|
||||
"startSetup": "Iniciar configuración",
|
||||
"scanTitle": "Escanea el código QR",
|
||||
@@ -2944,10 +2944,10 @@
|
||||
"verifying": "Verificando...",
|
||||
"verifyAndEnable": "Verificar y habilitar",
|
||||
"enabledTitle": "2FA está habilitado.",
|
||||
"enabledDescription": "Guarde los códigos de respaldo antes de cerrar esta ventana.",
|
||||
"saveCodesTitle": "Guarde sus códigos de respaldo",
|
||||
"enabledDescription": "Guarde los códigos de backup antes de cerrar esta ventana.",
|
||||
"saveCodesTitle": "Guarde sus códigos de backup",
|
||||
"saveCodesDescription": "Cada código se puede usar una vez si pierde el acceso a su aplicación de autenticación.",
|
||||
"backupCodes": "Códigos de respaldo",
|
||||
"backupCodes": "Códigos de backup",
|
||||
"copyAll": "Copiar todo",
|
||||
"finish": "Finalizar"
|
||||
},
|
||||
@@ -3218,12 +3218,12 @@
|
||||
"requiresAttention": "Requiere atención",
|
||||
"warnings": "Advertencias",
|
||||
"monitorClosely": "Monitorear de cerca",
|
||||
"backups": "Copias de seguridad"
|
||||
"backups": "Backups"
|
||||
},
|
||||
"tabs": {
|
||||
"logs": "Registros",
|
||||
"events": "Eventos",
|
||||
"backups": "Copias de seguridad",
|
||||
"backups": "Backups",
|
||||
"notifications": "Notificaciones"
|
||||
},
|
||||
"filters": {
|
||||
@@ -3290,13 +3290,13 @@
|
||||
},
|
||||
"empty": {
|
||||
"logs": "Ningún registro coincide con los filtros seleccionados.",
|
||||
"backups": "No se encontraron copias de seguridad.",
|
||||
"backups": "No se encontraron backups.",
|
||||
"notifications": "No se encontraron notificaciones."
|
||||
},
|
||||
"loadMore": "Cargar más ({count} restante)",
|
||||
"backups": {
|
||||
"vm": "Copias de seguridad de máquinas virtuales",
|
||||
"lxc": "copias de seguridad LXC",
|
||||
"vm": "Backups de VM",
|
||||
"lxc": "Backups de LXC",
|
||||
"totalSize": "tamaño total"
|
||||
},
|
||||
"modals": {
|
||||
@@ -3304,8 +3304,8 @@
|
||||
"logDescription": "Información completa sobre esta entrada de registro",
|
||||
"eventTitle": "Detalles del evento",
|
||||
"eventDescription": "Información completa sobre este evento",
|
||||
"backupTitle": "Detalles de la copia de seguridad",
|
||||
"backupDescription": "Información completa sobre esta copia de seguridad",
|
||||
"backupTitle": "Detalles del backup",
|
||||
"backupDescription": "Información completa sobre este backup",
|
||||
"notificationTitle": "Detalles de notificación",
|
||||
"notificationDescription": "Información completa sobre esta notificación"
|
||||
},
|
||||
@@ -3920,40 +3920,40 @@
|
||||
"at": "en",
|
||||
"backup": "Backup",
|
||||
"backupTimeLabel": "Creado",
|
||||
"deleteBackendTitle": "Eliminar esta copia de seguridad",
|
||||
"deleteBackendTitle": "Eliminar este backup",
|
||||
"deleteBorgDescription": "Esto elimina el archivo Borg del repositorio.",
|
||||
"deleteBorgTitle": "Eliminar archivo Borg",
|
||||
"deleteLocalDescription": "Esto elimina el archivo de copia de seguridad local del disco.",
|
||||
"deleteLocalDescription": "Esto elimina del disco el archivo de backup local.",
|
||||
"deleteLocalTitle": "Eliminar archivo local",
|
||||
"deletePbsDescription": "Esto elimina la instantánea de PBS del almacén de datos.",
|
||||
"deletePbsTitle": "Eliminar instantánea de PBS",
|
||||
"companionFile": "archivo complementario",
|
||||
"descriptionAfter": "",
|
||||
"descriptionBefore": "Explorar y restaurar copias de seguridad encontradas en",
|
||||
"downloadTitle": "Descarga esta copia de seguridad",
|
||||
"emptyAfter": "copias de seguridad todavía.",
|
||||
"descriptionBefore": "Explorar y restaurar los backups encontrados en",
|
||||
"downloadTitle": "Descargar este backup",
|
||||
"emptyAfter": "backups todavía.",
|
||||
"emptyBefore": "No",
|
||||
"encrypted": "cifrado",
|
||||
"encryptedTitle": "Esta copia de seguridad está cifrada",
|
||||
"filesInBackup": "Archivos en copia de seguridad",
|
||||
"encryptedTitle": "Este backup está cifrado",
|
||||
"filesInBackup": "Archivos del backup",
|
||||
"group": "Grupo",
|
||||
"host": "Host",
|
||||
"importKeyToDownloadTitle": "Importe el archivo de claves correspondiente antes de descargarlo",
|
||||
"importKeyToRestoreTitle": "Importe el archivo de claves correspondiente antes de restaurar",
|
||||
"importKeyToViewTitle": "Importe el archivo de claves correspondiente antes de ver el contenido",
|
||||
"inspectTitle": "Inspeccionar copia de seguridad",
|
||||
"inspectTitle": "Inspeccionar backup",
|
||||
"job": "Tarea",
|
||||
"loadFailed": "No se pudieron cargar las copias de seguridad",
|
||||
"loadFailed": "No se pudieron cargar los backups",
|
||||
"packedSizeLabel": "Tamaño empaquetado",
|
||||
"remoteQueryWarning": "Las copias de seguridad remotas pueden tardar un momento en consultarse.",
|
||||
"restoreTitle": "Restaurar desde esta copia de seguridad",
|
||||
"remoteQueryWarning": "Los backups remotos pueden tardar un momento en consultarse.",
|
||||
"restoreTitle": "Restaurar desde este backup",
|
||||
"startingExport": "Preparando descarga...",
|
||||
"title": "Copias de seguridad",
|
||||
"viewContentsTitle": "Ver contenidos de la copia de seguridad",
|
||||
"emptyMessage": "No se encontraron archivos de respaldo."
|
||||
"title": "Backups",
|
||||
"viewContentsTitle": "Ver el contenido del backup",
|
||||
"emptyMessage": "No se encontraron archivos de backup."
|
||||
},
|
||||
"backends": {
|
||||
"borgDescription": "Repositorio Borg para copias de seguridad de host deduplicadas.",
|
||||
"borgDescription": "Repositorio Borg para backups del host deduplicados.",
|
||||
"borgDescriptionTimerOnly": "Los destinos Borg son utilizados por los temporizadores Monitor, no por los tareas PVE vzdump.",
|
||||
"borgShortDescription": "Repositorio Borg deduplicado",
|
||||
"local": "Local",
|
||||
@@ -3970,17 +3970,17 @@
|
||||
"yes": "Sí"
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Configuración de copia de seguridad"
|
||||
"title": "Configuración de backup"
|
||||
},
|
||||
"contents": {
|
||||
"driversToReinstall": "Controladores para reinstalar",
|
||||
"extracting": "Extracción de instantáneas y análisis: esto puede llevar un minuto en copias de seguridad grandes...",
|
||||
"extracting": "Extrayendo y analizando el snapshot: puede tardar un minuto en backups grandes...",
|
||||
"filesShowing": "Mostrando {shown} de {filtered}{filteredLabel}{total}",
|
||||
"filesTitle": "Archivos ({count}{plus})",
|
||||
"filesTruncated": "· lista truncada en 5000: abre la instantánea manualmente para ver el resto",
|
||||
"filtered": "filtrado",
|
||||
"manifest": "Manifiesto",
|
||||
"manifestMissingBefore": "Esta copia de seguridad no incluye manifest.json. Los archivos de metadatos siguientes contienen información equivalente:",
|
||||
"manifestMissingBefore": "Este backup no incluye manifest.json. Los archivos de metadatos siguientes contienen información equivalente:",
|
||||
"metadataFiles": "Archivos de metadatos",
|
||||
"network": "Red",
|
||||
"noDrivers": "No hay controladores para reinstalar.",
|
||||
@@ -3991,15 +3991,15 @@
|
||||
"restorePlanUnavailable": "Plan de restauración no disponible: {error}",
|
||||
"rollbackPlan": "plan de reversión",
|
||||
"storage": "Almacenamiento",
|
||||
"title": "Contenido de la copia de seguridad",
|
||||
"title": "Contenido del backup",
|
||||
"totalFiles": "(total {count})"
|
||||
},
|
||||
"deleteJob": {
|
||||
"attachedToStorage": "Adjunto al almacenamiento PVE",
|
||||
"attachedWarning": "Esta tarea está conectada a un gancho de almacenamiento PVE. Al eliminarla, también se elimina el gancho del Monitor de ese almacenamiento.",
|
||||
"description": "Se eliminarán los metadatos del temporizador y del monitor para esta tarea.",
|
||||
"timerWarning": "Se eliminará el temporizador systemd. Las copias de seguridad ya creadas permanecen en su destino.",
|
||||
"title": "Eliminar tarea de copia de seguridad"
|
||||
"timerWarning": "Se eliminará el temporizador de systemd. Los backups ya creados permanecen en su destino.",
|
||||
"title": "Eliminar trabajo de backup"
|
||||
},
|
||||
"destinations": {
|
||||
"absolutePathHelp": "Utilice una ruta absoluta en este host.",
|
||||
@@ -4008,16 +4008,16 @@
|
||||
"addLocalDestinationTitle": "Agregar destino local",
|
||||
"addPbsRepository": "Agregar repositorio de PBS",
|
||||
"appendAuthorizedKeyBefore": "Agregue la clave pública generada a",
|
||||
"backupsKeptBefore": "Las copias de seguridad existentes se guardan en",
|
||||
"backupsKeptBefore": "Los backups existentes se conservan en",
|
||||
"borgEncryptionInherited": "El cifrado Borg está controlado por el repositorio.",
|
||||
"builtIn": "Incorporado",
|
||||
"builtInDefault": "Valor predeterminado incorporado",
|
||||
"capacityUnavailable": "Capacidad no disponible",
|
||||
"configureDescription": "Elija dónde se deben almacenar las copias de seguridad del host.",
|
||||
"configureDescription": "Elija dónde se deben almacenar los backups del host.",
|
||||
"configureDestination": "Configurar destino",
|
||||
"createsSshKeyAfter": "cuando guardas.",
|
||||
"createsSshKeyBefore": "Monitor crea una clave SSH en",
|
||||
"datacenterStorage": "Centro de datos → Copia de seguridad",
|
||||
"datacenterStorage": "Datacenter → Backup",
|
||||
"defaultUserBefore": "Usuario predeterminado:",
|
||||
"editDestinationTitle": "Editar destino {type}",
|
||||
"editLocalDestinationTitle": "Editar destino local",
|
||||
@@ -4030,7 +4030,7 @@
|
||||
"leavePasswordBlank": "Déjelo en blanco para conservar la contraseña guardada.",
|
||||
"localAttachHelpBefore": "Utilice una ruta local montada. Para unidades USB, móntelas primero en",
|
||||
"localAttachHelpMiddle": "unidades USB",
|
||||
"localManualPickHelp": "Elija una ruta local para la salida de respaldo de una sola vez.",
|
||||
"localManualPickHelp": "Elija una ruta local para guardar el backup puntual.",
|
||||
"localPathHelpAfter": "u otra ruta montada.",
|
||||
"localPathHelpBefore": "Ejemplo:",
|
||||
"localPickHelpBefore": "Elija una carpeta local o una ruta USB montada.",
|
||||
@@ -4050,8 +4050,8 @@
|
||||
"remoteSsh": "SSH remoto",
|
||||
"remoteSshDescription": "Repositorio Borg en otro host a través de SSH.",
|
||||
"removeAndDeleteJobs": "Eliminar destino y eliminar tareas",
|
||||
"removeDescriptionPlain": "Esto solo elimina el destino del Monitor. Las copias de seguridad existentes permanecen donde están.",
|
||||
"removeDescriptionWithData": "Esto elimina el destino del Monitor. Las copias de seguridad existentes permanecen donde están.",
|
||||
"removeDescriptionPlain": "Esto solo elimina el destino del Monitor. Los backups existentes permanecen donde están.",
|
||||
"removeDescriptionWithData": "Esto elimina el destino del Monitor. Los backups existentes permanecen donde están.",
|
||||
"removeDestination": "Eliminar destino",
|
||||
"removeTitle": "Eliminar este destino",
|
||||
"saveAnotherBorgTitle": "Guarde otro destino Borg",
|
||||
@@ -4067,7 +4067,7 @@
|
||||
"title": "Destinos",
|
||||
"unmountTitle": "Desmontar este destino",
|
||||
"whereIsBorgRepo": "¿Dónde está el repositorio Borg?",
|
||||
"backupsKeptMessage": "los archivos de copia de seguridad existentes no se eliminan.",
|
||||
"backupsKeptMessage": "Los archivos de backup existentes no se eliminan.",
|
||||
"localAttachHelpMessage": "Monte primero las unidades USB.Déjelo en blanco para usar la ruta de almacenamiento PVE + /dump (alternativa: /var/lib/vz/dump).",
|
||||
"sshAuthorizedKeyHelp": "revise la línea siguiente antes de agregarla a ~/.ssh/authorized_keys para {user} en el host remoto.",
|
||||
"sshKeyHelpMessage": "ruta de clave privada en este host.Utilice {action}, luego revise y agregue la línea de clave pública mostrada al ~/.ssh/authorized_keys del usuario remoto.",
|
||||
@@ -4077,27 +4077,27 @@
|
||||
},
|
||||
"encryption": {
|
||||
"autoDetectedFromPveStorage": "Detectado desde el almacenamiento PVE",
|
||||
"borgPassphraseLossBefore": "Si pierde la frase de contraseña de Borg, las copias de seguridad cifradas no se podrán restaurar.",
|
||||
"borgPassphraseLossBefore": "Si pierde la frase de contraseña de Borg, los backups cifrados no se podrán restaurar.",
|
||||
"borgPassphraseOnlyWay": "Esta frase de contraseña es la única forma de desbloquear el repositorio.",
|
||||
"borgPassphraseSavedAfter": "para que los tareas programados puedan ejecutarse sin supervisión.",
|
||||
"borgPassphraseSavedAt": "La frase de contraseña se guarda en",
|
||||
"encryptBackups": "Cifrar copias de seguridad",
|
||||
"encryptThisBackup": "Cifre esta copia de seguridad",
|
||||
"encryptBackups": "Cifrar backups",
|
||||
"encryptThisBackup": "Cifrar este backup",
|
||||
"encryptedModeTitle": "Modo cifrado",
|
||||
"encryptedRepokey": "Repokey cifrado",
|
||||
"encryptedRepokeyDescription": "Borg almacena la clave dentro del repositorio, protegida por su frase de contraseña.",
|
||||
"existingKeyAt": "Clave existente en",
|
||||
"generateManualDescription": "Genere un nuevo archivo de claves y utilícelo para esta copia de seguridad.",
|
||||
"generateManualDescription": "Genere un nuevo archivo de claves y utilícelo para este backup.",
|
||||
"generateNewKeyfile": "Generar nuevo archivo de claves",
|
||||
"generateNewKeyfileDescriptionAfter": "y lo almacena localmente para futuras restauraciones de PBS.",
|
||||
"generateNewKeyfileDescriptionBefore": "Monitor crea",
|
||||
"importExistingKeyfile": "Importar archivo de claves existente",
|
||||
"importExistingKeyfileDescription": "Cargue un archivo de claves que coincida con las copias de seguridad cifradas existentes.",
|
||||
"importKeyfileHelpAfter": "antes de restaurar las copias de seguridad cifradas de PBS.",
|
||||
"importExistingKeyfileDescription": "Cargue un archivo de claves que coincida con los backups cifrados existentes.",
|
||||
"importKeyfileHelpAfter": "antes de restaurar los backups cifrados de PBS.",
|
||||
"importKeyfileHelpBefore": "Importe el archivo de claves correspondiente a",
|
||||
"importManualDescription": "Utilice un archivo de claves existente de esta máquina.",
|
||||
"noEncryption": "Sin cifrado",
|
||||
"noEncryptionDescription": "Las copias de seguridad se almacenan sin clave de cifrado.",
|
||||
"noEncryptionDescription": "Los backups se almacenan sin clave de cifrado.",
|
||||
"noKeyStored": "No hay ningún archivo de claves almacenado localmente.",
|
||||
"none": "Ninguno",
|
||||
"passphraseNotSaved": "La frase de contraseña no se guarda.",
|
||||
@@ -4108,9 +4108,9 @@
|
||||
"pbsHelpMiddle": "como el archivo clave",
|
||||
"pveKeyWillBeCopied": "La clave de cifrado PVE se copiará para las restauraciones del Monitor.",
|
||||
"savePassphraseNow": "Guardar frase de contraseña para ejecuciones programadas",
|
||||
"unrecoverable": "Sin la clave, las copias de seguridad cifradas no se pueden restaurar.",
|
||||
"unrecoverable": "Sin la clave, los backups cifrados no se pueden restaurar.",
|
||||
"usesSharedKeyfileAt": "Utiliza un archivo de claves compartido en",
|
||||
"pbsHelp": "Monitor usa {keyfile} con {option} para copias de seguridad y restauraciones cifradas de PBS.El archivo de claves se almacena con el modo {mode}.Mantenga una copia segura fuera de este host."
|
||||
"pbsHelp": "Monitor usa {keyfile} con {option} para backups y restauraciones cifradas de PBS. El archivo de claves se almacena con el modo {mode}. Mantenga una copia segura fuera de este host."
|
||||
},
|
||||
"errors": {
|
||||
"backendNoStagingPath": "El backend no devolvió una ruta de preparación.",
|
||||
@@ -4123,7 +4123,7 @@
|
||||
"passphrasesDontMatch": "Las frases de contraseña no coinciden.",
|
||||
"pickKeyfile": "Elija un archivo clave primero.",
|
||||
"preflightFailed": "Error de verificación previa: {error}",
|
||||
"proxmoxBackupClientOutput": "Salida del cliente de copia de seguridad de proxmox:",
|
||||
"proxmoxBackupClientOutput": "Salida de proxmox-backup-client:",
|
||||
"recoveryPassphraseRequired": "Se requiere una contraseña de recuperación.",
|
||||
"repoNameAndSnapshotRequired": "Se requieren el nombre del repositorio y la instantánea.",
|
||||
"runFailed": "Error de ejecución: {error}",
|
||||
@@ -4136,8 +4136,8 @@
|
||||
"absolutePathHintMiddle": "no es lo mismo que",
|
||||
"descriptionAfter": "(perfil manual o programado, predeterminado o personalizado) en la parte superior de la lista de perfiles predeterminados. Útil para datos de aplicaciones, carpetas de configuración personalizadas y rutas similares. ({count} configurado)",
|
||||
"descriptionBefore": "Las rutas agregadas aquí están incluidas en",
|
||||
"empty": "Aún no hay rutas personalizadas: las copias de seguridad utilizan únicamente el perfil predeterminado.",
|
||||
"everyBackup": "cada copia de seguridad",
|
||||
"empty": "Aún no hay rutas personalizadas: los backups usan únicamente el perfil predeterminado.",
|
||||
"everyBackup": "cada backup",
|
||||
"missingOnDisk": "falta en el disco",
|
||||
"removeTitle": "Eliminar esta ruta personalizada",
|
||||
"title": "Rutas personalizadas"
|
||||
@@ -4147,8 +4147,8 @@
|
||||
"archiveNameLabel": "Nombre del archivo",
|
||||
"backend": "backend",
|
||||
"backendLabel": "backend",
|
||||
"backupGroupLabel": "grupo de respaldo",
|
||||
"backupId": "ID de respaldo",
|
||||
"backupGroupLabel": "Grupo de backup",
|
||||
"backupId": "ID del backup",
|
||||
"bootMode": "Modo de arranque",
|
||||
"borgIdLabel": "Identificación de Borg",
|
||||
"borgRepository": "repositorio borg",
|
||||
@@ -4226,7 +4226,7 @@
|
||||
"sshPort": "Puerto SSH"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "copias de seguridad.",
|
||||
"attachDescriptionAfter": "backups.",
|
||||
"attachDescriptionBefore": "Une Monitor a una tarea PVE vzdump y ejecútela después del",
|
||||
"attachDescriptionMiddle": "evento para seleccionados",
|
||||
"attachToPveJob": "Unirse a una tarea de Backup en PVE",
|
||||
@@ -4254,22 +4254,22 @@
|
||||
"loadFailed": "No se pudieron cargar tareas",
|
||||
"loadingJob": "Cargando tarea...",
|
||||
"manualOneShot": "manual / de una sola vez",
|
||||
"manualOneShotDescription": "Copia de seguridad de un solo disparo: capturada en el momento del disparo. No se puede volver a ejecutar ni editar.",
|
||||
"manualOneShotDescription": "Backup puntual: capturado en el momento en que se lanzó. No se puede volver a ejecutar ni editar.",
|
||||
"neverRun": "nunca corras",
|
||||
"newScheduledJob": "Nueva tarea programada",
|
||||
"newScheduledJobDescription": "Cree una tarea de copia de seguridad del host recurrente.",
|
||||
"newScheduledJobDescription": "Cree un trabajo de backup del host recurrente.",
|
||||
"nextRun": "Próxima ejecución",
|
||||
"nextRunLabel": "Próxima ejecución",
|
||||
"nextRunTitle": "Próxima ejecución programada",
|
||||
"noCompatiblePveJob": "No se encontró ningún tarea de copia de seguridad PVE compatible",
|
||||
"noCompatiblePveJob": "No se encontró ningún trabajo de backup de PVE compatible",
|
||||
"noCompatiblePveJobDescriptionAfter": "primero.",
|
||||
"noCompatiblePveJobDescriptionBefore": "Cree o habilite una tarea de copia de seguridad PVE para",
|
||||
"noCompatiblePveJobDescriptionBefore": "Cree o habilite un trabajo de backup de PVE para",
|
||||
"noCompatiblePveJobDescriptionMiddle": "en",
|
||||
"notAvailableForBorg": "no disponible para Borg",
|
||||
"openJobTitle": "Abrir detalles de la tarea",
|
||||
"parentPveJobHelpAfter": "se ejecuta con éxito.",
|
||||
"parentPveJobHelpBefore": "El monitor se ejecuta después de esta tarea PVE.",
|
||||
"pbsGroupHelp": "Este se convierte en el grupo de respaldo de PBS.",
|
||||
"pbsGroupHelp": "Este será el grupo de backup en PBS.",
|
||||
"pickParentPveJob": "Elige la tarea PVE principal",
|
||||
"retentionTitle": "Retención",
|
||||
"runAttachedTitle": "Activar una ejecución ad hoc ahora (el temporizador PVE mantiene su propio cronograma)",
|
||||
@@ -4283,12 +4283,12 @@
|
||||
"waitingForRunner": "Esperando a que se inicie el ejecutor..."
|
||||
},
|
||||
"keyfileActions": {
|
||||
"deleteDialogDescription": "Las copias de seguridad cifradas de PBS necesitarán el mismo archivo de claves antes de poder restaurarlas.",
|
||||
"deleteDialogDescription": "Los backups cifrados de PBS necesitarán el mismo archivo de claves para poder restaurarse.",
|
||||
"deleteDialogTitle": "¿Eliminar el archivo de claves de PBS local?",
|
||||
"deleteTitle": "Eliminar archivo de claves local",
|
||||
"deleteWarningDownloadsAfter": "hasta que importes la clave coincidente nuevamente.",
|
||||
"deleteWarningDownloadsBefore": "Las descargas y restauraciones de copias de seguridad cifradas fallarán",
|
||||
"deleteWarningNewBackups": "Las nuevas copias de seguridad cifradas no se pueden ejecutar sin un archivo de claves.",
|
||||
"deleteWarningDownloadsBefore": "Las descargas y restauraciones de backups cifrados fallarán",
|
||||
"deleteWarningNewBackups": "Los nuevos backups cifrados no se pueden ejecutar sin un archivo de claves.",
|
||||
"deleteWarningRecoveryBlobs": "Los blobs de recuperación de PBS permanecen en el servidor.",
|
||||
"downloadTitle": "Descargar archivo clave",
|
||||
"fingerprintTitle": "Mostrar huella digital clave",
|
||||
@@ -4301,8 +4301,8 @@
|
||||
"pveKeyDescriptionBefore": "Existe una clave PVE en",
|
||||
"pveKeyDescriptionMiddle": "Coincide con el cifrado de almacenamiento PVE",
|
||||
"pveKeyDetected": "Clave PVE detectada",
|
||||
"uploadDialogDescriptionAfter": "Las copias de seguridad cifradas creadas con una clave diferente seguirán requiriendo su clave original.",
|
||||
"uploadDialogDescriptionBefore": "Cargue el archivo de claves utilizado para las copias de seguridad cifradas de PBS.",
|
||||
"uploadDialogDescriptionAfter": "Los backups cifrados creados con otra clave seguirán necesitando su clave original.",
|
||||
"uploadDialogDescriptionBefore": "Cargue el archivo de claves usado para los backups cifrados de PBS.",
|
||||
"uploadDialogTitle": "Cargar archivo de claves PBS",
|
||||
"uploadFromMachine": "Subir desde esta máquina",
|
||||
"uploadTitle": "Cargar archivo de claves",
|
||||
@@ -4312,16 +4312,16 @@
|
||||
},
|
||||
"keyfileError": {
|
||||
"currentlyInstalled": "Actualmente instalado",
|
||||
"description": "Esta copia de seguridad está cifrada y necesita el archivo de claves PBS correspondiente.",
|
||||
"description": "Este backup está cifrado y necesita el archivo de claves de PBS correspondiente.",
|
||||
"importFrom": "Importe el archivo de claves correspondiente desde",
|
||||
"importPath": "Copia de seguridad → Destinos → Archivo de claves PBS",
|
||||
"requiredManifest": "Requerido por respaldo",
|
||||
"importPath": "Backup → Destinos → Archivo de claves PBS",
|
||||
"requiredManifest": "Requerido por el backup",
|
||||
"retryHint": "e inténtalo de nuevo.",
|
||||
"title": "Se requiere un archivo de claves coincidente"
|
||||
},
|
||||
"keyfileGate": {
|
||||
"descriptionAfter": "antes de que esta acción pueda continuar.",
|
||||
"descriptionBefore": "Esta copia de seguridad cifrada necesita el archivo de claves correspondiente",
|
||||
"descriptionBefore": "Este backup cifrado necesita el archivo de claves correspondiente",
|
||||
"title": "Se requiere archivo de claves"
|
||||
},
|
||||
"keyfileManagement": {
|
||||
@@ -4332,14 +4332,14 @@
|
||||
"currentPbsUploadStays": "Las cargas de recuperación de PBS existentes permanecen en su lugar.",
|
||||
"downloadKeyfile": "Descargar archivo clave",
|
||||
"hideOptions": "Ocultar opciones",
|
||||
"keepLocalDescriptionAfter": "y guarde su propia copia de seguridad en otro lugar.",
|
||||
"keepLocalDescriptionAfter": "y guarde su propia copia en otro lugar.",
|
||||
"keepLocalDescriptionBefore": "Mantenga la clave solo en este host",
|
||||
"keepLocalOnly": "Mantener solo local",
|
||||
"keepOffsiteCopy": "Mantenga una copia fuera del sitio",
|
||||
"keyfileInstalled": "Archivo de claves instalado",
|
||||
"startUploading": "Comience a cargar blobs de recuperación en PBS",
|
||||
"stopUploading": "Dejar de cargar blobs de recuperación",
|
||||
"thisBackupReusesKeyfile": "Esta copia de seguridad reutiliza el archivo de claves PBS compartido.",
|
||||
"thisBackupReusesKeyfile": "Este backup reutiliza el archivo de claves PBS compartido.",
|
||||
"title": "Gestión de archivos clave",
|
||||
"updatePassphrase": "Actualizar frase de contraseña",
|
||||
"uploadNo": "No, mantenlo solo local",
|
||||
@@ -4365,14 +4365,14 @@
|
||||
"manifest": {
|
||||
"bootMode": "Modo de arranque",
|
||||
"builtBy": "Construido por",
|
||||
"componentsAtBackup": "Componentes en respaldo",
|
||||
"componentsAtBackup": "Componentes al hacer el backup",
|
||||
"compression": "Compresión",
|
||||
"createdAt": "Creado en",
|
||||
"deviceCount": "{count} dispositivo(s)",
|
||||
"encrypted": "cifrado",
|
||||
"gpus": "GPU",
|
||||
"interfaceCount": "{count} interfaz(es)",
|
||||
"lxcsAtBackup": "LXC en respaldo",
|
||||
"lxcsAtBackup": "LXC al hacer el backup",
|
||||
"memory": "Memoria",
|
||||
"nics": "NIC",
|
||||
"pathCount": "{count} rutas",
|
||||
@@ -4380,17 +4380,17 @@
|
||||
"proxmoxVersion": "Versión Proxmox",
|
||||
"roles": "Roles",
|
||||
"sourceHost": "Host de origen",
|
||||
"vmsAtBackup": "VM en respaldo"
|
||||
"vmsAtBackup": "VM al hacer el backup"
|
||||
},
|
||||
"manual": {
|
||||
"description": "Ejecute una copia de seguridad única del host ahora.",
|
||||
"inProgress": "Copia de seguridad manual en progreso",
|
||||
"oneShotDescription": "Las copias de seguridad únicas se mantienen como tareas congelados para que puedas inspeccionar su registro más tarde.",
|
||||
"description": "Ejecute ahora un backup puntual del host.",
|
||||
"inProgress": "Backup manual en curso",
|
||||
"oneShotDescription": "Los backups puntuales se guardan como trabajos congelados para poder consultar su log más tarde.",
|
||||
"reopenLogTitle": "Reabrir registro",
|
||||
"run": "Ejecutar",
|
||||
"runBackup": "Ejecutar copia de seguridad",
|
||||
"runOneShotBackup": "Ejecute una copia de seguridad única",
|
||||
"title": "Copia de seguridad manual",
|
||||
"runBackup": "Ejecutar backup",
|
||||
"runOneShotBackup": "Ejecutar backup puntual",
|
||||
"title": "Backup manual",
|
||||
"viewProgress": "Ver progreso"
|
||||
},
|
||||
"paths": {
|
||||
@@ -4462,31 +4462,31 @@
|
||||
"restore": {
|
||||
"ackAfter": "las VM, LXC y componentes enumerados anteriormente. Esto es irreversible.",
|
||||
"ackBefore": "Entiendo que la restauración completa",
|
||||
"afterRebootHint": "Después del reinicio, esta pestaña Copias de seguridad mostrará el progreso posterior a la restauración en vivo con el tiempo estimado, el estado de cada componente, el final del registro y el delta de reversión. Si las notificaciones de Telegram/Discord/ntfy están configuradas, también recibirás el evento \"Restauración del host finalizada\".",
|
||||
"backupKernel": "Núcleo de respaldo",
|
||||
"afterRebootHint": "Después del reinicio, esta pestaña Backups mostrará el progreso posterior a la restauración en vivo con el tiempo estimado, el estado de cada componente, el final del registro y el delta de reversión. Si las notificaciones de Telegram/Discord/ntfy están configuradas, también recibirás el evento \"Restauración del host finalizada\".",
|
||||
"backupKernel": "Kernel del backup",
|
||||
"blockedEncryptedTitle": "Restauración cifrada bloqueada",
|
||||
"blockedPathsHint": "Las rutas vinculadas al kernel/arranque están atenuadas y no se pueden seleccionar: el destino ejecuta un kernel más nuevo.",
|
||||
"chooseModeTitle": "Restaurar – elegir modo",
|
||||
"complete": "Completo",
|
||||
"completeDescription": "Restaura todo desde la copia de seguridad.",
|
||||
"completeDescription": "Restaura todo desde el backup.",
|
||||
"completeDescriptionAfter": "en el menú TUI del shell. El terminal que se abre a continuación aún le pedirá que confirme solo seguro, seguro+reinicio o todo a la vez antes de tocar el host.",
|
||||
"completeDescriptionBefore": "Reproduce todo lo que hay en la copia de seguridad. Equivalente a recoger",
|
||||
"completeDescriptionBefore": "Reproduce todo lo que hay en el backup. Equivale a elegir",
|
||||
"completeTitle": "restauración completa",
|
||||
"crossKernelDescription": "Tanto la restauración completa como la personalizada omiten automáticamente las rutas vinculadas al kernel/arranque para evitar un pánico del kernel en el siguiente arranque. Todo lo demás (VM, LXC, red, componentes, rutas personalizadas) se restaura normalmente.",
|
||||
"crossKernelTitle": "Restauración entre núcleos: solo subconjunto seguro",
|
||||
"customByPaths": "Personalizado por rutas",
|
||||
"customByPathsDescription": "Elija exactamente qué rutas restaurar. Muestra lo que hay realmente en esta copia de seguridad (perfil predeterminado + sus rutas personalizadas).",
|
||||
"customByPathsDescription": "Elija exactamente qué rutas restaurar. Muestra lo que hay realmente en este backup (perfil predeterminado + sus rutas personalizadas).",
|
||||
"customByPathsTitle": "Restauración personalizada por rutas",
|
||||
"customPickDescriptionAfter": "env var: el mismo código descendente que la TUI. El flujo de bash detectará las rutas necesarias para el reinicio (kernel, módulos, fstab, etc.) y podrá programarlas para el próximo arranque desde allí.",
|
||||
"customPickDescriptionBefore": "Elija las rutas para restaurar. Su selección alimenta",
|
||||
"customPickDescriptionMiddle": "a través del",
|
||||
"destructiveTitle": "La restauración completa QUITARÁ lo siguiente (creado después de la copia de seguridad)",
|
||||
"destructiveTitle": "La restauración completa ELIMINARÁ lo siguiente (creado después del backup)",
|
||||
"noPathsMatch": "Ninguna ruta coincide con el filtro.",
|
||||
"noRestorablePaths": "Esta copia de seguridad no incluye rutas restaurables; aquí solo tiene sentido la restauración completa.",
|
||||
"noRestorablePaths": "Este backup no incluye rutas restaurables; aquí solo tiene sentido la restauración completa.",
|
||||
"olderThanTargetKernel": "es más antiguo que el kernel de destino",
|
||||
"operatorConfigDescription": "Las siguientes configuraciones creadas por el operador se fusionarán en el destino automáticamente, sin tocar los valores predeterminados vinculados al kernel. Se ejecuta tanto en restauración completa como personalizada.",
|
||||
"operatorConfigTitle": "Configuración del operador reaplicada mediante fusión independiente del kernel",
|
||||
"pathsInBackup": "({count} rutas en la copia de seguridad)",
|
||||
"pathsInBackup": "({count} rutas en el backup)",
|
||||
"pathsSelected": "{count} rutas seleccionadas",
|
||||
"pathsSkipped": "Rutas que se omitirán ({count})",
|
||||
"permanentlyDelete": "eliminar permanentemente",
|
||||
@@ -4500,28 +4500,28 @@
|
||||
"tickAckToEnable": "Marque el reconocimiento de arriba para habilitarlo."
|
||||
},
|
||||
"retention": {
|
||||
"daily": "A diario",
|
||||
"hourly": "Cada hora",
|
||||
"keepDaily": "Mantener diariamente",
|
||||
"keepHourly": "mantener cada hora",
|
||||
"keepLast": "mantener el último",
|
||||
"keepMonthly": "Mantener mensualmente",
|
||||
"keepWeekly": "Mantener semanal",
|
||||
"keepYearly": "Mantener anualmente",
|
||||
"last": "Último",
|
||||
"monthly": "Mensual",
|
||||
"none": "Ninguno",
|
||||
"noneAccumulate": "Sin límites de retención: las copias de seguridad se acumulan hasta que se eliminan manualmente.",
|
||||
"daily": "Diarios",
|
||||
"hourly": "Por hora",
|
||||
"keepDaily": "Conservar diarios",
|
||||
"keepHourly": "Conservar por hora",
|
||||
"keepLast": "Conservar los últimos",
|
||||
"keepMonthly": "Conservar mensuales",
|
||||
"keepWeekly": "Conservar semanales",
|
||||
"keepYearly": "Conservar anuales",
|
||||
"last": "Últimos",
|
||||
"monthly": "Mensuales",
|
||||
"none": "Ninguna",
|
||||
"noneAccumulate": "Sin límites de retención: los backups se acumulan hasta que se eliminan manualmente.",
|
||||
"title": "Retención",
|
||||
"weekly": "Semanalmente",
|
||||
"yearly": "Anual",
|
||||
"weekly": "Semanales",
|
||||
"yearly": "Anuales",
|
||||
"zeroDisables": "0 desactiva este límite."
|
||||
},
|
||||
"rollback": {
|
||||
"components": "Componentes:",
|
||||
"configsIncluded": "Configuraciones incluidas en la copia de seguridad",
|
||||
"configsIncluded": "Configuraciones incluidas en el backup",
|
||||
"lxcConfigs": "Configuraciones de LXC:",
|
||||
"noDifferences": "No se detectaron diferencias en el estado del host: el estado de la copia de seguridad coincide con el host actual (o la copia de seguridad no incluía /etc/pve).",
|
||||
"noDifferences": "No se detectaron diferencias en el estado del host: el estado del backup coincide con el host actual (o el backup no incluía /etc/pve).",
|
||||
"pveOnlyHint": "Sólo se restaura la configuración /etc/pve: las imágenes de disco permanecen donde residen.",
|
||||
"vmConfigs": "Configuraciones de máquina virtual:"
|
||||
},
|
||||
@@ -4595,7 +4595,7 @@
|
||||
"typeDeviceExactly": "Escriba la ruta del dispositivo EXACTAMENTE para confirmar:",
|
||||
"typeDeviceToConfirm": "Escriba la ruta del dispositivo para confirmar",
|
||||
"unformatted": "Sin formato",
|
||||
"unmountConfirm": "¿Desmontar {path}? Cualquier tarea de copia de seguridad que apunte a esta ruta fallará hasta que lo vuelva a montar.",
|
||||
"unmountConfirm": "¿Desmontar {path}? Cualquier trabajo de backup que apunte a esta ruta fallará hasta que se vuelva a montar.",
|
||||
"unmounted": "Desmontado",
|
||||
"uuidTitle": "uuid: {uuid}",
|
||||
"wipeAndFormat": "Limpiar y formatear"
|
||||
@@ -4603,6 +4603,77 @@
|
||||
"validation": {
|
||||
"invalidLabel": "Etiqueta no válida."
|
||||
},
|
||||
"viewSwitch": {
|
||||
"ariaLabel": "Cambiar entre los backups del host y los de VM y LXC",
|
||||
"host": "Host",
|
||||
"guests": "VM y LXC"
|
||||
},
|
||||
"vmJobs": {
|
||||
"title": "Trabajos de backup de VM y CT",
|
||||
"subtitle": "Los trabajos de backup programados de Proxmox, los mismos que en Datacenter → Backup.",
|
||||
"newJob": "Nuevo trabajo",
|
||||
"loadFailed": "No se pudieron leer los trabajos de backup",
|
||||
"empty": "Todavía no hay ningún trabajo de backup. Crea el primero con «Nuevo trabajo».",
|
||||
"allGuests": "Todas las VM y CT",
|
||||
"noGuests": "Ninguna VM ni CT seleccionada",
|
||||
"storageDefault": "la del almacenamiento",
|
||||
"hostBackup": "Backup del host",
|
||||
"attachedHost": "Backups del host que se ejecutan con este trabajo: {ids}",
|
||||
"created": "Trabajo creado",
|
||||
"updated": "Trabajo actualizado",
|
||||
"deleted": "Trabajo {id} eliminado",
|
||||
"toggledOn": "Trabajo {id} activado",
|
||||
"toggledOff": "Trabajo {id} desactivado",
|
||||
"started": "Backup de {id} iniciado como tarea de Proxmox en: {nodes}.",
|
||||
"startedPartial": "No se pudo iniciar en: {nodes}.",
|
||||
"dialogNew": "Nuevo trabajo de backup",
|
||||
"dialogEdit": "Editar el trabajo {id}",
|
||||
"fieldName": "Nombre (opcional)",
|
||||
"fieldNameHint": "Letras, números y guiones. Vacío: lo genera Proxmox.",
|
||||
"fieldGuests": "Qué se incluye",
|
||||
"fieldAll": "Todas las VM y contenedores",
|
||||
"fieldStorage": "Almacenamiento de destino",
|
||||
"selectStorage": "Seleccionar…",
|
||||
"noStorage": "Ningún almacenamiento admite backups. Añade uno en Proxmox, Datacenter → Storage.",
|
||||
"fieldSchedule": "Programación",
|
||||
"scheduleCustom": "Personalizada",
|
||||
"schedulePlaceholder": "p. ej. mon..fri 01:30",
|
||||
"fieldMode": "Modo",
|
||||
"modeSnapshot": "Snapshot (sin parada)",
|
||||
"modeSuspend": "Suspend (pausa la VM o CT)",
|
||||
"modeStop": "Stop (apaga la VM o CT)",
|
||||
"fieldCompress": "Compresión",
|
||||
"compressNone": "Ninguna",
|
||||
"fieldNotes": "Notas de los backups",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando…",
|
||||
"needStorage": "Elige el almacenamiento de destino",
|
||||
"needGuests": "Selecciona al menos una VM o CT",
|
||||
"saveFailed": "No se pudo guardar el trabajo",
|
||||
"actionFailed": "La operación falló",
|
||||
"deleteTitle": "Eliminar el trabajo {id}",
|
||||
"deleteBody": "Solo se elimina la programación. Los backups ya hechos siguen en el almacenamiento.",
|
||||
"deleteAttached": "Estos backups del host se ejecutan con este trabajo y dejarán de ejecutarse: {ids}",
|
||||
"storageAttached": "Estos backups del host se ejecutan con este trabajo y esperan su almacenamiento: {ids}. Con otro almacenamiento dejan de ejecutarse.",
|
||||
"deleteConfirm": "Eliminar trabajo",
|
||||
"disableTitle": "Desactivar el trabajo {id}",
|
||||
"disableBody": "El trabajo queda configurado pero no se ejecuta hasta que se active de nuevo.",
|
||||
"runTitle": "Ejecutar ahora el trabajo {id}",
|
||||
"runBody": "El backup empieza ahora con los ajustes del trabajo, como tarea de Proxmox.",
|
||||
"runStop": "El modo de este trabajo es stop: sus VM y CT se apagan durante el backup.",
|
||||
"labelSchedule": "Programación",
|
||||
"labelStorage": "Almacenamiento",
|
||||
"labelMode": "Modo",
|
||||
"labelRetention": "Retención",
|
||||
"presets": {
|
||||
"daily02": "Cada día a las 02:00",
|
||||
"daily03": "Cada día a las 03:00",
|
||||
"sunday02": "Cada domingo a las 02:00",
|
||||
"every6h": "Cada 6 horas",
|
||||
"hourly": "Cada hora"
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"friday": "Viernes",
|
||||
"monday": "Lunes",
|
||||
@@ -4952,12 +5023,12 @@
|
||||
"noOutput": "(sin salida)"
|
||||
},
|
||||
"rollback": {
|
||||
"empty": "No faltan entradas en este host en la copia de seguridad restaurada.",
|
||||
"empty": "En este host no hay entradas que faltaran en el backup restaurado.",
|
||||
"showCleanup": "Mostrar comandos de limpieza manual",
|
||||
"description": "Estas entradas existen en este host pero no estaban en la copia de seguridad restaurada. Revíselos antes de eliminar cualquier cosa.",
|
||||
"vms": "VM creadas después de la copia de seguridad",
|
||||
"lxcs": "LXC creados después de la copia de seguridad",
|
||||
"components": "Componentes instalados después de la copia de seguridad",
|
||||
"description": "Estas entradas existen en este host pero no estaban en el backup restaurado. Revíselas antes de eliminar nada.",
|
||||
"vms": "VM creadas después del backup",
|
||||
"lxcs": "LXC creados después del backup",
|
||||
"components": "Componentes instalados después del backup",
|
||||
"uninstallComponentCommand": "# desinstalar {name} manualmente a través de ProxMenux → Hardware y GPU"
|
||||
},
|
||||
"sections": {
|
||||
@@ -5475,7 +5546,7 @@
|
||||
},
|
||||
"host_recovery": {
|
||||
"title": "Recuperación del host",
|
||||
"explanation": "Respaldar los invitados no restaura el nodo. Esta comprobación mira si la configuración del propio nodo —sus definiciones de almacenamiento, su red, sus usuarios— está guardada en algún sitio, que es de lo que depende poder reconstruir el host.",
|
||||
"explanation": "Hacer backup de los invitados no restaura el nodo. Esta comprobación mira si la configuración del propio nodo —sus definiciones de almacenamiento, su red, sus usuarios— está guardada en algún sitio, que es de lo que depende poder reconstruir el host.",
|
||||
"nextStep": {
|
||||
"attention": "Revisa los registros de configuración del host de la lista. Cada uno tiene algo que mirar: una ejecución que falló, un destino que ya no está, o una copia más antigua que el límite en uso.",
|
||||
"scheduledOnly": "Un temporizador producirá backups de la configuración del host, pero todavía no hay ninguno guardado. Hasta que se ejecute el primero, la configuración del nodo no está protegida.",
|
||||
@@ -6083,7 +6154,7 @@
|
||||
"location": "Ubicación",
|
||||
"backupDestination": "Destino de backups",
|
||||
"unprotected": "sin backup",
|
||||
"storageDiagramNote": "Dónde residen los discos de los invitados y qué destino los respalda.",
|
||||
"storageDiagramNote": "Dónde residen los discos de los invitados y qué destino les hace backup.",
|
||||
"unprotectedGuests": "{count} invitados sin trabajo de backup",
|
||||
"allProtected": "Todos los invitados están cubiertos por un trabajo de backup",
|
||||
"allProtectedNote": "La cobertura indica que un trabajo selecciona al invitado; el resultado de los backups se evalúa aparte.",
|
||||
@@ -6185,7 +6256,7 @@
|
||||
"thin_pool_usage_percent": "Revisión de ocupación de thin pool (%)",
|
||||
"thin_overprovision_ratio": "Ratio de sobreaprovisionamiento thin",
|
||||
"zfs_scrub_days": "Intervalo de scrub de ZFS (días)",
|
||||
"backup_fallback_days": "Plazo de respaldo para la antigüedad (días)",
|
||||
"backup_fallback_days": "Plazo alternativo para la antigüedad del backup (días)",
|
||||
"backup_schedule_grace_ratio": "Margen sobre el calendario (ratio)",
|
||||
"certificate_expiry_days": "Aviso de caducidad de certificado (días)",
|
||||
"memory_overcommit_ratio": "Ratio de sobreasignación de memoria",
|
||||
|
||||
@@ -4603,6 +4603,77 @@
|
||||
"validation": {
|
||||
"invalidLabel": "Étiquette invalide."
|
||||
},
|
||||
"viewSwitch": {
|
||||
"ariaLabel": "Basculer entre les sauvegardes de l'hôte et celles des VM et LXC",
|
||||
"host": "Hôte",
|
||||
"guests": "VM et LXC"
|
||||
},
|
||||
"vmJobs": {
|
||||
"title": "Tâches de sauvegarde des VM et CT",
|
||||
"subtitle": "Les tâches de sauvegarde planifiées de Proxmox, les mêmes que dans Datacenter → Backup.",
|
||||
"newJob": "Nouvelle tâche",
|
||||
"loadFailed": "Impossible de lire les tâches de sauvegarde",
|
||||
"empty": "Aucune tâche de sauvegarde. Créez la première avec « Nouvelle tâche ».",
|
||||
"allGuests": "Toutes les VM et CT",
|
||||
"noGuests": "Aucune VM ni CT sélectionnée",
|
||||
"storageDefault": "celle du stockage",
|
||||
"hostBackup": "Sauvegarde de l'hôte",
|
||||
"attachedHost": "Sauvegardes de l'hôte exécutées avec cette tâche : {ids}",
|
||||
"created": "Tâche créée",
|
||||
"updated": "Tâche mise à jour",
|
||||
"deleted": "Tâche {id} supprimée",
|
||||
"toggledOn": "Tâche {id} activée",
|
||||
"toggledOff": "Tâche {id} désactivée",
|
||||
"started": "Sauvegarde de {id} lancée comme tâche Proxmox sur : {nodes}.",
|
||||
"startedPartial": "Impossible de la lancer sur : {nodes}.",
|
||||
"dialogNew": "Nouvelle tâche de sauvegarde",
|
||||
"dialogEdit": "Modifier la tâche {id}",
|
||||
"fieldName": "Nom (facultatif)",
|
||||
"fieldNameHint": "Lettres, chiffres et tirets. Vide : Proxmox en génère un.",
|
||||
"fieldGuests": "Éléments à sauvegarder",
|
||||
"fieldAll": "Toutes les VM et tous les conteneurs",
|
||||
"fieldStorage": "Stockage de destination",
|
||||
"selectStorage": "Sélectionner…",
|
||||
"noStorage": "Aucun stockage n'accepte les sauvegardes. Ajoutez-en un dans Proxmox, Datacenter → Storage.",
|
||||
"fieldSchedule": "Planification",
|
||||
"scheduleCustom": "Personnalisée",
|
||||
"schedulePlaceholder": "p. ex. mon..fri 01:30",
|
||||
"fieldMode": "Mode",
|
||||
"modeSnapshot": "Snapshot (sans interruption)",
|
||||
"modeSuspend": "Suspend (met la VM ou le CT en pause)",
|
||||
"modeStop": "Stop (arrête la VM ou le CT)",
|
||||
"fieldCompress": "Compression",
|
||||
"compressNone": "Aucune",
|
||||
"fieldNotes": "Notes des sauvegardes",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"needStorage": "Choisissez le stockage de destination",
|
||||
"needGuests": "Sélectionnez au moins une VM ou un CT",
|
||||
"saveFailed": "La tâche n'a pas pu être enregistrée",
|
||||
"actionFailed": "L'opération a échoué",
|
||||
"deleteTitle": "Supprimer la tâche {id}",
|
||||
"deleteBody": "Seule la planification est supprimée. Les sauvegardes déjà faites restent dans le stockage.",
|
||||
"deleteAttached": "Ces sauvegardes de l'hôte s'exécutent avec cette tâche et ne s'exécuteront plus : {ids}",
|
||||
"storageAttached": "Ces sauvegardes de l'hôte s'exécutent avec cette tâche et attendent son stockage : {ids}. Avec un autre stockage, elles ne s'exécutent plus.",
|
||||
"deleteConfirm": "Supprimer la tâche",
|
||||
"disableTitle": "Désactiver la tâche {id}",
|
||||
"disableBody": "La tâche reste configurée mais ne s'exécute plus tant qu'elle n'est pas réactivée.",
|
||||
"runTitle": "Exécuter la tâche {id} maintenant",
|
||||
"runBody": "La sauvegarde démarre maintenant avec les réglages de la tâche, comme tâche Proxmox.",
|
||||
"runStop": "Le mode de cette tâche est stop : ses VM et CT sont arrêtés pendant la sauvegarde.",
|
||||
"labelSchedule": "Planification",
|
||||
"labelStorage": "Stockage",
|
||||
"labelMode": "Mode",
|
||||
"labelRetention": "Rétention",
|
||||
"presets": {
|
||||
"daily02": "Tous les jours à 02:00",
|
||||
"daily03": "Tous les jours à 03:00",
|
||||
"sunday02": "Tous les dimanches à 02:00",
|
||||
"every6h": "Toutes les 6 heures",
|
||||
"hourly": "Toutes les heures"
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"friday": "Vendredi",
|
||||
"monday": "Lundi",
|
||||
|
||||
@@ -4603,6 +4603,77 @@
|
||||
"validation": {
|
||||
"invalidLabel": "Etichetta non valida."
|
||||
},
|
||||
"viewSwitch": {
|
||||
"ariaLabel": "Passa dai backup dell’host a quelli di VM e LXC",
|
||||
"host": "Host",
|
||||
"guests": "VM e LXC"
|
||||
},
|
||||
"vmJobs": {
|
||||
"title": "Job di backup di VM e CT",
|
||||
"subtitle": "I job di backup pianificati di Proxmox, gli stessi di Datacenter → Backup.",
|
||||
"newJob": "Nuovo job",
|
||||
"loadFailed": "Impossibile leggere i job di backup",
|
||||
"empty": "Nessun job di backup. Crea il primo con “Nuovo job”.",
|
||||
"allGuests": "Tutte le VM/CT",
|
||||
"noGuests": "Nessuna selezione",
|
||||
"storageDefault": "predefinita dello storage",
|
||||
"hostBackup": "Backup host",
|
||||
"attachedHost": "Backup dell’host eseguiti con questo job: {ids}",
|
||||
"created": "Job creato",
|
||||
"updated": "Job aggiornato",
|
||||
"deleted": "Job {id} eliminato",
|
||||
"toggledOn": "Job {id} attivato",
|
||||
"toggledOff": "Job {id} disattivato",
|
||||
"started": "Backup di {id} avviato come task di Proxmox su: {nodes}.",
|
||||
"startedPartial": "Impossibile avviarlo su: {nodes}.",
|
||||
"dialogNew": "Nuovo job di backup",
|
||||
"dialogEdit": "Modifica job {id}",
|
||||
"fieldName": "Nome (facoltativo)",
|
||||
"fieldNameHint": "Lettere, numeri e trattini. Vuoto: lo genera Proxmox.",
|
||||
"fieldGuests": "Guest da salvare",
|
||||
"fieldAll": "Tutte le VM e i container",
|
||||
"fieldStorage": "Storage di destinazione",
|
||||
"selectStorage": "Seleziona…",
|
||||
"noStorage": "Nessuno storage accetta backup. Aggiungine uno in Proxmox, Datacenter → Storage.",
|
||||
"fieldSchedule": "Pianificazione",
|
||||
"scheduleCustom": "Personalizzato",
|
||||
"schedulePlaceholder": "es. mon..fri 01:30",
|
||||
"fieldMode": "Modalità",
|
||||
"modeSnapshot": "Snapshot (senza interruzione)",
|
||||
"modeSuspend": "Suspend (mette in pausa il guest)",
|
||||
"modeStop": "Stop (arresta il guest)",
|
||||
"fieldCompress": "Compressione",
|
||||
"compressNone": "Nessuna",
|
||||
"fieldNotes": "Note dei backup",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio…",
|
||||
"needStorage": "Scegli lo storage di destinazione",
|
||||
"needGuests": "Seleziona almeno una VM/CT",
|
||||
"saveFailed": "Salvataggio non riuscito",
|
||||
"actionFailed": "Operazione non riuscita",
|
||||
"deleteTitle": "Elimina job {id}",
|
||||
"deleteBody": "Viene rimossa solo la pianificazione. Gli archivi di backup già creati restano sullo storage.",
|
||||
"deleteAttached": "Questi backup dell’host vengono eseguiti con questo job e non verranno più eseguiti: {ids}",
|
||||
"storageAttached": "Questi backup dell’host vengono eseguiti con questo job e si aspettano il suo storage: {ids}. Con un altro storage non vengono più eseguiti.",
|
||||
"deleteConfirm": "Elimina job",
|
||||
"disableTitle": "Disattiva job {id}",
|
||||
"disableBody": "Il job resta configurato ma non viene eseguito finché non viene riattivato.",
|
||||
"runTitle": "Esegui ora il job {id}",
|
||||
"runBody": "Il backup parte ora con le impostazioni del job, come task di Proxmox.",
|
||||
"runStop": "La modalità di questo job è stop: i guest vengono arrestati durante il backup.",
|
||||
"labelSchedule": "Pianificazione",
|
||||
"labelStorage": "Storage",
|
||||
"labelMode": "Modalità",
|
||||
"labelRetention": "Retention",
|
||||
"presets": {
|
||||
"daily02": "Ogni giorno alle 02:00",
|
||||
"daily03": "Ogni giorno alle 03:00",
|
||||
"sunday02": "Ogni domenica alle 02:00",
|
||||
"every6h": "Ogni 6 ore",
|
||||
"hourly": "Ogni ora"
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"friday": "Venerdì",
|
||||
"monday": "Lunedì",
|
||||
|
||||
@@ -4603,6 +4603,77 @@
|
||||
"validation": {
|
||||
"invalidLabel": "Etiqueta inválida."
|
||||
},
|
||||
"viewSwitch": {
|
||||
"ariaLabel": "Alternar entre os backups do host e os de VM e LXC",
|
||||
"host": "Host",
|
||||
"guests": "VM e LXC"
|
||||
},
|
||||
"vmJobs": {
|
||||
"title": "Tarefas de backup de VM e CT",
|
||||
"subtitle": "As tarefas de backup agendadas do Proxmox, as mesmas de Datacenter → Backup.",
|
||||
"newJob": "Nova tarefa",
|
||||
"loadFailed": "Não foi possível ler as tarefas de backup",
|
||||
"empty": "Ainda não há nenhuma tarefa de backup. Crie a primeira com “Nova tarefa”.",
|
||||
"allGuests": "Todas as VM e CT",
|
||||
"noGuests": "Nenhuma VM ou CT selecionada",
|
||||
"storageDefault": "a do armazenamento",
|
||||
"hostBackup": "Backup do host",
|
||||
"attachedHost": "Backups do host executados com esta tarefa: {ids}",
|
||||
"created": "Tarefa criada",
|
||||
"updated": "Tarefa atualizada",
|
||||
"deleted": "Tarefa {id} eliminada",
|
||||
"toggledOn": "Tarefa {id} ativada",
|
||||
"toggledOff": "Tarefa {id} desativada",
|
||||
"started": "Backup de {id} iniciado como tarefa do Proxmox em: {nodes}.",
|
||||
"startedPartial": "Não foi possível iniciar em: {nodes}.",
|
||||
"dialogNew": "Nova tarefa de backup",
|
||||
"dialogEdit": "Editar a tarefa {id}",
|
||||
"fieldName": "Nome (opcional)",
|
||||
"fieldNameHint": "Letras, números e hífenes. Vazio: o Proxmox gera um.",
|
||||
"fieldGuests": "O que incluir",
|
||||
"fieldAll": "Todas as VM e contêineres",
|
||||
"fieldStorage": "Armazenamento de destino",
|
||||
"selectStorage": "Selecionar…",
|
||||
"noStorage": "Nenhum armazenamento aceita backups. Adicione um no Proxmox, Datacenter → Storage.",
|
||||
"fieldSchedule": "Agendamento",
|
||||
"scheduleCustom": "Personalizado",
|
||||
"schedulePlaceholder": "p. ex. mon..fri 01:30",
|
||||
"fieldMode": "Modo",
|
||||
"modeSnapshot": "Snapshot (sem paragem)",
|
||||
"modeSuspend": "Suspend (pausa a VM ou CT)",
|
||||
"modeStop": "Stop (desliga a VM ou CT)",
|
||||
"fieldCompress": "Compressão",
|
||||
"compressNone": "Nenhuma",
|
||||
"fieldNotes": "Notas dos backups",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"saving": "A guardar…",
|
||||
"needStorage": "Escolha o armazenamento de destino",
|
||||
"needGuests": "Selecione pelo menos uma VM ou CT",
|
||||
"saveFailed": "Não foi possível guardar a tarefa",
|
||||
"actionFailed": "A operação falhou",
|
||||
"deleteTitle": "Eliminar a tarefa {id}",
|
||||
"deleteBody": "Só o agendamento é removido. Os backups já feitos ficam no armazenamento.",
|
||||
"deleteAttached": "Estes backups do host são executados com esta tarefa e deixarão de ser executados: {ids}",
|
||||
"storageAttached": "Estes backups do host são executados com esta tarefa e esperam o seu armazenamento: {ids}. Com outro armazenamento deixam de ser executados.",
|
||||
"deleteConfirm": "Eliminar tarefa",
|
||||
"disableTitle": "Desativar a tarefa {id}",
|
||||
"disableBody": "A tarefa fica configurada mas não é executada até ser ativada de novo.",
|
||||
"runTitle": "Executar agora a tarefa {id}",
|
||||
"runBody": "O backup começa agora com as definições da tarefa, como tarefa do Proxmox.",
|
||||
"runStop": "O modo desta tarefa é stop: as suas VM e CT são desligadas durante o backup.",
|
||||
"labelSchedule": "Agendamento",
|
||||
"labelStorage": "Armazenamento",
|
||||
"labelMode": "Modo",
|
||||
"labelRetention": "Retenção",
|
||||
"presets": {
|
||||
"daily02": "Todos os dias às 02:00",
|
||||
"daily03": "Todos os dias às 03:00",
|
||||
"sunday02": "Todos os domingos às 02:00",
|
||||
"every6h": "A cada 6 horas",
|
||||
"hourly": "A cada hora"
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"friday": "Sexta-feira",
|
||||
"monday": "Segunda-feira",
|
||||
|
||||
@@ -4602,6 +4602,77 @@
|
||||
"validation": {
|
||||
"invalidLabel": "Neplatný štítok."
|
||||
},
|
||||
"viewSwitch": {
|
||||
"ariaLabel": "Prepnúť medzi zálohami hostiteľa a zálohami VM a LXC",
|
||||
"host": "Hostiteľ",
|
||||
"guests": "VM a LXC"
|
||||
},
|
||||
"vmJobs": {
|
||||
"title": "Úlohy zálohovania VM a CT",
|
||||
"subtitle": "Naplánované úlohy zálohovania Proxmoxu, tie isté ako v Datacenter → Backup.",
|
||||
"newJob": "Nová úloha",
|
||||
"loadFailed": "Úlohy zálohovania sa nepodarilo načítať",
|
||||
"empty": "Zatiaľ žiadna úloha zálohovania. Vytvorte prvú cez „Nová úloha“.",
|
||||
"allGuests": "Všetky VM a CT",
|
||||
"noGuests": "Nie je vybraná žiadna VM ani CT",
|
||||
"storageDefault": "predvolená podľa úložiska",
|
||||
"hostBackup": "Záloha hostiteľa",
|
||||
"attachedHost": "Zálohy hostiteľa, ktoré sa spúšťajú s touto úlohou: {ids}",
|
||||
"created": "Úloha vytvorená",
|
||||
"updated": "Úloha aktualizovaná",
|
||||
"deleted": "Úloha {id} odstránená",
|
||||
"toggledOn": "Úloha {id} povolená",
|
||||
"toggledOff": "Úloha {id} zakázaná",
|
||||
"started": "Záloha {id} spustená ako úloha Proxmoxu na: {nodes}.",
|
||||
"startedPartial": "Nepodarilo sa spustiť na: {nodes}.",
|
||||
"dialogNew": "Nová úloha zálohovania",
|
||||
"dialogEdit": "Upraviť úlohu {id}",
|
||||
"fieldName": "Názov (voliteľný)",
|
||||
"fieldNameHint": "Písmená, číslice a pomlčky. Prázdne: vygeneruje ho Proxmox.",
|
||||
"fieldGuests": "Čo zálohovať",
|
||||
"fieldAll": "Všetky VM a kontajnery",
|
||||
"fieldStorage": "Cieľové úložisko",
|
||||
"selectStorage": "Vybrať…",
|
||||
"noStorage": "Žiadne úložisko neprijíma zálohy. Pridajte ho v Proxmoxe, Datacenter → Storage.",
|
||||
"fieldSchedule": "Plán",
|
||||
"scheduleCustom": "Vlastný",
|
||||
"schedulePlaceholder": "napr. mon..fri 01:30",
|
||||
"fieldMode": "Režim",
|
||||
"modeSnapshot": "Snapshot (bez výpadku)",
|
||||
"modeSuspend": "Suspend (pozastaví VM alebo CT)",
|
||||
"modeStop": "Stop (vypne VM alebo CT)",
|
||||
"fieldCompress": "Kompresia",
|
||||
"compressNone": "Žiadna",
|
||||
"fieldNotes": "Poznámky k zálohám",
|
||||
"cancel": "Zrušiť",
|
||||
"save": "Uložiť",
|
||||
"saving": "Ukladá sa…",
|
||||
"needStorage": "Vyberte cieľové úložisko",
|
||||
"needGuests": "Vyberte aspoň jednu VM alebo CT",
|
||||
"saveFailed": "Úlohu sa nepodarilo uložiť",
|
||||
"actionFailed": "Operácia zlyhala",
|
||||
"deleteTitle": "Odstrániť úlohu {id}",
|
||||
"deleteBody": "Odstráni sa iba plán. Už vytvorené zálohy zostanú v úložisku.",
|
||||
"deleteAttached": "Tieto zálohy hostiteľa sa spúšťajú s touto úlohou a prestanú sa spúšťať: {ids}",
|
||||
"storageAttached": "Tieto zálohy hostiteľa sa spúšťajú s touto úlohou a očakávajú jej úložisko: {ids}. S iným úložiskom sa prestanú spúšťať.",
|
||||
"deleteConfirm": "Odstrániť úlohu",
|
||||
"disableTitle": "Zakázať úlohu {id}",
|
||||
"disableBody": "Úloha zostane nakonfigurovaná, ale nespustí sa, kým ju znova nepovolíte.",
|
||||
"runTitle": "Spustiť úlohu {id} teraz",
|
||||
"runBody": "Záloha sa spustí teraz s nastaveniami úlohy, ako úloha Proxmoxu.",
|
||||
"runStop": "Režim tejto úlohy je stop: jej VM a CT sa počas zálohy vypnú.",
|
||||
"labelSchedule": "Plán",
|
||||
"labelStorage": "Úložisko",
|
||||
"labelMode": "Režim",
|
||||
"labelRetention": "Uchovávanie",
|
||||
"presets": {
|
||||
"daily02": "Každý deň o 02:00",
|
||||
"daily03": "Každý deň o 03:00",
|
||||
"sunday02": "Každú nedeľu o 02:00",
|
||||
"every6h": "Každých 6 hodín",
|
||||
"hourly": "Každú hodinu"
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"friday": "piatok",
|
||||
"monday": "pondelok",
|
||||
|
||||
@@ -4603,6 +4603,77 @@
|
||||
"validation": {
|
||||
"invalidLabel": "Ogiltig etikett."
|
||||
},
|
||||
"viewSwitch": {
|
||||
"ariaLabel": "Växla mellan värdens säkerhetskopior och säkerhetskopior för VM och LXC",
|
||||
"host": "Värd",
|
||||
"guests": "VM och LXC"
|
||||
},
|
||||
"vmJobs": {
|
||||
"title": "Säkerhetskopieringsjobb för VM och CT",
|
||||
"subtitle": "Proxmox schemalagda säkerhetskopieringsjobb, samma som under Datacenter → Backup.",
|
||||
"newJob": "Nytt jobb",
|
||||
"loadFailed": "Det gick inte att läsa säkerhetskopieringsjobben",
|
||||
"empty": "Inget säkerhetskopieringsjobb ännu. Skapa det första med ”Nytt jobb”.",
|
||||
"allGuests": "Alla VM och CT",
|
||||
"noGuests": "Ingen VM eller CT vald",
|
||||
"storageDefault": "lagringens standard",
|
||||
"hostBackup": "Värdsäkerhetskopia",
|
||||
"attachedHost": "Värdsäkerhetskopior som körs med det här jobbet: {ids}",
|
||||
"created": "Jobb skapat",
|
||||
"updated": "Jobb uppdaterat",
|
||||
"deleted": "Jobb {id} borttaget",
|
||||
"toggledOn": "Jobb {id} aktiverat",
|
||||
"toggledOff": "Jobb {id} inaktiverat",
|
||||
"started": "Säkerhetskopiering av {id} startad som Proxmox-uppgift på: {nodes}.",
|
||||
"startedPartial": "Kunde inte startas på: {nodes}.",
|
||||
"dialogNew": "Nytt säkerhetskopieringsjobb",
|
||||
"dialogEdit": "Redigera jobb {id}",
|
||||
"fieldName": "Namn (valfritt)",
|
||||
"fieldNameHint": "Bokstäver, siffror och bindestreck. Tomt: Proxmox skapar ett.",
|
||||
"fieldGuests": "Vad som ska säkerhetskopieras",
|
||||
"fieldAll": "Alla VM och behållare",
|
||||
"fieldStorage": "Mållagring",
|
||||
"selectStorage": "Välj…",
|
||||
"noStorage": "Ingen lagring tar emot säkerhetskopior. Lägg till en i Proxmox, Datacenter → Storage.",
|
||||
"fieldSchedule": "Schema",
|
||||
"scheduleCustom": "Anpassat",
|
||||
"schedulePlaceholder": "t.ex. mon..fri 01:30",
|
||||
"fieldMode": "Läge",
|
||||
"modeSnapshot": "Snapshot (utan avbrott)",
|
||||
"modeSuspend": "Suspend (pausar VM eller CT)",
|
||||
"modeStop": "Stop (stänger av VM eller CT)",
|
||||
"fieldCompress": "Komprimering",
|
||||
"compressNone": "Ingen",
|
||||
"fieldNotes": "Anteckningar för säkerhetskopior",
|
||||
"cancel": "Avbryt",
|
||||
"save": "Spara",
|
||||
"saving": "Sparar…",
|
||||
"needStorage": "Välj mållagring",
|
||||
"needGuests": "Välj minst en VM eller CT",
|
||||
"saveFailed": "Jobbet kunde inte sparas",
|
||||
"actionFailed": "Åtgärden misslyckades",
|
||||
"deleteTitle": "Ta bort jobb {id}",
|
||||
"deleteBody": "Endast schemat tas bort. Redan skapade säkerhetskopior ligger kvar i lagringen.",
|
||||
"deleteAttached": "De här värdsäkerhetskopiorna körs med det här jobbet och slutar köras: {ids}",
|
||||
"storageAttached": "De här värdsäkerhetskopiorna körs med det här jobbet och förväntar sig dess lagring: {ids}. Med en annan lagring slutar de köras.",
|
||||
"deleteConfirm": "Ta bort jobb",
|
||||
"disableTitle": "Inaktivera jobb {id}",
|
||||
"disableBody": "Jobbet finns kvar men körs inte förrän det aktiveras igen.",
|
||||
"runTitle": "Kör jobb {id} nu",
|
||||
"runBody": "Säkerhetskopieringen startar nu med jobbets inställningar, som en Proxmox-uppgift.",
|
||||
"runStop": "Det här jobbets läge är stop: dess VM och CT stängs av under säkerhetskopieringen.",
|
||||
"labelSchedule": "Schema",
|
||||
"labelStorage": "Lagring",
|
||||
"labelMode": "Läge",
|
||||
"labelRetention": "Lagringstid",
|
||||
"presets": {
|
||||
"daily02": "Varje dag kl. 02:00",
|
||||
"daily03": "Varje dag kl. 03:00",
|
||||
"sunday02": "Varje söndag kl. 02:00",
|
||||
"every6h": "Var 6:e timme",
|
||||
"hourly": "Varje timme"
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"friday": "fredag",
|
||||
"monday": "måndag",
|
||||
|
||||
@@ -176,6 +176,7 @@ for locale in en de es fr it pt sk sv; do
|
||||
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/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"
|
||||
|
||||
@@ -93,6 +93,7 @@ from flask_security_routes import security_bp # noqa: E402
|
||||
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 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
|
||||
@@ -229,6 +230,7 @@ app.register_blueprint(security_bp)
|
||||
app.register_blueprint(notification_bp)
|
||||
app.register_blueprint(oci_bp)
|
||||
app.register_blueprint(audit_bp)
|
||||
app.register_blueprint(vm_backup_bp)
|
||||
|
||||
# Initialize terminal / WebSocket routes
|
||||
init_terminal_routes(app)
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
ProxMenux Monitor - VM/CT backup jobs (Datacenter > Backup)
|
||||
|
||||
Thin wrapper around `pvesh` on /cluster/backup. The jobs are the ones of
|
||||
Proxmox itself: whatever is created or changed here is what the Proxmox
|
||||
web interface shows, and the other way round.
|
||||
|
||||
Based on the contribution of MattiaC46 (PR #421).
|
||||
|
||||
Endpoints, all under /api/vm-backup-jobs:
|
||||
GET / list of jobs
|
||||
POST / create a job
|
||||
GET /options storages that accept backups + VMs and CTs
|
||||
GET /<id> one job
|
||||
PUT /<id> change a job
|
||||
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
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from jwt_middleware import require_admin_scope, require_auth
|
||||
|
||||
vm_backup_bp = Blueprint("vm_backup", __name__)
|
||||
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
STORAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
VMID_RE = re.compile(r"^\d{1,9}(,\d{1,9})*$")
|
||||
PRUNE_RE = re.compile(r"^keep-(?:last|hourly|daily|weekly|monthly|yearly|all)=\d{1,5}"
|
||||
r"(?:,keep-(?:last|hourly|daily|weekly|monthly|yearly|all)=\d{1,5})*$")
|
||||
SCHEDULE_RE = re.compile(r"^[A-Za-z0-9*.,:/ -]{1,80}$")
|
||||
NODE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,62}$")
|
||||
MODES = {"snapshot", "suspend", "stop"}
|
||||
COMPRESS = {"0", "gzip", "lzo", "zstd"}
|
||||
|
||||
# What a job stores about itself and its schedule; everything else is a
|
||||
# vzdump option and is passed on when the job is run by hand.
|
||||
JOB_ONLY = {"id", "type", "schedule", "enabled", "next-run", "comment", "repeat-missed", "digest", "node",
|
||||
"starttime", "dow"}
|
||||
|
||||
HOST_BACKUP_JOBS = "/var/lib/proxmenux/backup-jobs"
|
||||
|
||||
|
||||
def _pvesh(args, timeout=25):
|
||||
"""Run pvesh without a shell. Returns (ok, output)."""
|
||||
try:
|
||||
done = subprocess.run(["pvesh", *args], capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "pvesh timed out"
|
||||
except FileNotFoundError:
|
||||
return False, "pvesh not found (a Proxmox VE node is required)"
|
||||
if done.returncode != 0:
|
||||
return False, (done.stderr or done.stdout).strip()
|
||||
return True, done.stdout
|
||||
|
||||
|
||||
def _pvesh_json(args):
|
||||
ok, out = _pvesh([*args, "--output-format", "json"])
|
||||
if not ok:
|
||||
return False, out
|
||||
try:
|
||||
return True, json.loads(out or "null")
|
||||
except json.JSONDecodeError:
|
||||
return False, "pvesh returned an invalid answer"
|
||||
|
||||
|
||||
def _flat(value):
|
||||
"""pvesh answers some property strings as objects; a job stores them as text."""
|
||||
if isinstance(value, dict):
|
||||
return ",".join(f"{key}={val}" for key, val in value.items())
|
||||
if isinstance(value, bool):
|
||||
return 1 if value else 0
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_job(job):
|
||||
if isinstance(job, dict):
|
||||
for key in list(job):
|
||||
job[key] = _flat(job[key])
|
||||
return job
|
||||
|
||||
|
||||
def _enabled(job):
|
||||
return job.get("enabled", 1) in (1, True, "1")
|
||||
|
||||
|
||||
def _host_backups_attached():
|
||||
"""Host backups of ProxMenux that run with a Proxmox job: {job id: [host backup ids]}."""
|
||||
attached = {}
|
||||
try:
|
||||
names = sorted(os.listdir(HOST_BACKUP_JOBS))
|
||||
except OSError:
|
||||
return attached
|
||||
for name in names:
|
||||
if not name.endswith(".env"):
|
||||
continue
|
||||
parent = ""
|
||||
try:
|
||||
with open(os.path.join(HOST_BACKUP_JOBS, name)) as file:
|
||||
for line in file:
|
||||
if line.startswith("PVE_PARENT_JOB="):
|
||||
parent = line.split("=", 1)[1].strip().strip("'\"")
|
||||
except OSError:
|
||||
continue
|
||||
if parent:
|
||||
attached.setdefault(parent, []).append(name[:-4])
|
||||
return attached
|
||||
|
||||
|
||||
def _err(message, code=400):
|
||||
return jsonify({"success": False, "error": message}), code
|
||||
|
||||
|
||||
def _validate(data, partial=False):
|
||||
"""Check the payload and turn it into pvesh arguments. Returns ((args, deletes), error)."""
|
||||
args = []
|
||||
deletes = []
|
||||
|
||||
def has(key):
|
||||
return key in data and data[key] not in (None, "")
|
||||
|
||||
if "all" in data or "vmid" in data:
|
||||
if data.get("all") in (1, True, "1", "true"):
|
||||
args += ["--all", "1"]
|
||||
deletes.append("vmid")
|
||||
else:
|
||||
vmid = str(data.get("vmid", "")).replace(" ", "")
|
||||
if not VMID_RE.match(vmid):
|
||||
return None, "Select at least one VM or CT"
|
||||
args += ["--vmid", vmid, "--all", "0"]
|
||||
elif not partial:
|
||||
return None, "Either all the guests or a list of them is required"
|
||||
|
||||
if has("storage"):
|
||||
if not STORAGE_RE.match(str(data["storage"])):
|
||||
return None, "Invalid storage"
|
||||
args += ["--storage", str(data["storage"])]
|
||||
elif not partial:
|
||||
return None, "A storage is required"
|
||||
|
||||
if has("schedule"):
|
||||
schedule = str(data["schedule"]).strip()
|
||||
if not SCHEDULE_RE.match(schedule) or schedule.startswith("-"):
|
||||
return None, "Invalid schedule"
|
||||
args += ["--schedule", schedule]
|
||||
elif not partial:
|
||||
return None, "A schedule is required"
|
||||
|
||||
if has("mode"):
|
||||
if data["mode"] not in MODES:
|
||||
return None, "Invalid mode"
|
||||
args += ["--mode", data["mode"]]
|
||||
|
||||
if has("compress"):
|
||||
if str(data["compress"]) not in COMPRESS:
|
||||
return None, "Invalid compression"
|
||||
args += ["--compress", str(data["compress"])]
|
||||
|
||||
if "prune_backups" in data:
|
||||
prune = str(data.get("prune_backups") or "").replace(" ", "")
|
||||
if prune:
|
||||
if not PRUNE_RE.match(prune):
|
||||
return None, "Invalid retention (for example keep-last=3,keep-weekly=2)"
|
||||
args += ["--prune-backups", prune]
|
||||
else:
|
||||
deletes.append("prune-backups")
|
||||
|
||||
if "notes_template" in data:
|
||||
notes = str(data.get("notes_template") or "").strip()
|
||||
if notes:
|
||||
if len(notes) > 512 or notes.startswith("-") or "\n" in notes:
|
||||
return None, "Invalid notes"
|
||||
args += ["--notes-template", notes]
|
||||
else:
|
||||
deletes.append("notes-template")
|
||||
|
||||
if "enabled" in data:
|
||||
args += ["--enabled", "1" if data["enabled"] in (1, True, "1", "true") else "0"]
|
||||
|
||||
return (args, deletes), None
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs", methods=["GET"])
|
||||
@require_auth
|
||||
def list_jobs():
|
||||
ok, res = _pvesh_json(["get", "/cluster/backup"])
|
||||
if not ok:
|
||||
return _err(res, 500)
|
||||
attached = _host_backups_attached()
|
||||
jobs = []
|
||||
for job in res or []:
|
||||
job = _normalize_job(job)
|
||||
job["host_backups"] = attached.get(str(job.get("id")), [])
|
||||
jobs.append(job)
|
||||
return jsonify({"success": True, "jobs": jobs})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs/options", methods=["GET"])
|
||||
@require_auth
|
||||
def options():
|
||||
ok, storages = _pvesh_json(["get", "/storage"])
|
||||
if not ok:
|
||||
return _err(storages, 500)
|
||||
ok, guests = _pvesh_json(["get", "/cluster/resources", "--type", "vm"])
|
||||
if not ok:
|
||||
return _err(guests, 500)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"storages": [
|
||||
{"id": storage["storage"], "type": storage.get("type", "")}
|
||||
for storage in storages
|
||||
if "backup" in (storage.get("content") or "").split(",")
|
||||
],
|
||||
"guests": sorted(
|
||||
[
|
||||
{"vmid": guest["vmid"], "name": guest.get("name", "-"), "type": guest["type"],
|
||||
"node": guest.get("node", "")}
|
||||
for guest in guests
|
||||
if guest.get("type") in ("qemu", "lxc")
|
||||
],
|
||||
key=lambda guest: guest["vmid"],
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def create_job():
|
||||
data = request.get_json(silent=True) or {}
|
||||
parsed, error = _validate(data)
|
||||
if error:
|
||||
return _err(error)
|
||||
args, _deletes = parsed
|
||||
job_id = str(data.get("id") or "").strip()
|
||||
if job_id:
|
||||
if not ID_RE.match(job_id):
|
||||
return _err("Invalid job name")
|
||||
args += ["--id", job_id]
|
||||
if "enabled" not in data:
|
||||
args += ["--enabled", "1"]
|
||||
ok, out = _pvesh(["create", "/cluster/backup", *args])
|
||||
if not ok:
|
||||
return _err(out, 500)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs/<job_id>", methods=["GET"])
|
||||
@require_auth
|
||||
def get_job(job_id):
|
||||
if not ID_RE.match(job_id):
|
||||
return _err("Invalid job")
|
||||
ok, res = _pvesh_json(["get", f"/cluster/backup/{job_id}"])
|
||||
if not ok:
|
||||
return _err(res, 404)
|
||||
job = _normalize_job(res)
|
||||
job["host_backups"] = _host_backups_attached().get(job_id, [])
|
||||
return jsonify({"success": True, "job": job})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs/<job_id>", methods=["PUT"])
|
||||
@require_admin_scope
|
||||
def update_job(job_id):
|
||||
if not ID_RE.match(job_id):
|
||||
return _err("Invalid job")
|
||||
parsed, error = _validate(request.get_json(silent=True) or {}, partial=True)
|
||||
if error:
|
||||
return _err(error)
|
||||
args, deletes = parsed
|
||||
for key in dict.fromkeys(deletes):
|
||||
args += ["--delete", key]
|
||||
if not args:
|
||||
return _err("Nothing to change")
|
||||
ok, out = _pvesh(["set", f"/cluster/backup/{job_id}", *args])
|
||||
if not ok:
|
||||
return _err(out, 500)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs/<job_id>", methods=["DELETE"])
|
||||
@require_admin_scope
|
||||
def delete_job(job_id):
|
||||
if not ID_RE.match(job_id):
|
||||
return _err("Invalid job")
|
||||
ok, out = _pvesh(["delete", f"/cluster/backup/{job_id}"])
|
||||
if not ok:
|
||||
return _err(out, 500)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs/<job_id>/toggle", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def toggle_job(job_id):
|
||||
if not ID_RE.match(job_id):
|
||||
return _err("Invalid job")
|
||||
ok, job = _pvesh_json(["get", f"/cluster/backup/{job_id}"])
|
||||
if not ok:
|
||||
return _err(job, 404)
|
||||
state = "0" if _enabled(job) else "1"
|
||||
ok, out = _pvesh(["set", f"/cluster/backup/{job_id}", "--enabled", state])
|
||||
if not ok:
|
||||
return _err(out, 500)
|
||||
return jsonify({"success": True, "enabled": state == "1"})
|
||||
|
||||
|
||||
def _run_nodes(job):
|
||||
"""The nodes a job has something to back up on. Returns (nodes, error)."""
|
||||
ok, nodes = _pvesh_json(["get", "/nodes"])
|
||||
if not ok:
|
||||
return None, nodes
|
||||
online = [node["node"] for node in nodes or [] if node.get("status") == "online"]
|
||||
wanted = str(job.get("node") or "")
|
||||
if wanted:
|
||||
return ([wanted] if wanted in online else []), None
|
||||
if job.get("all") in (1, True, "1") or job.get("pool"):
|
||||
return online, None
|
||||
selected = {vmid for vmid in str(job.get("vmid", "")).replace(" ", "").split(",") if vmid}
|
||||
ok, guests = _pvesh_json(["get", "/cluster/resources", "--type", "vm"])
|
||||
if not ok:
|
||||
return None, guests
|
||||
hosting = {guest.get("node") for guest in guests or [] if str(guest.get("vmid")) in selected}
|
||||
return [node for node in online if node in hosting], None
|
||||
|
||||
|
||||
def _run_arguments(job):
|
||||
"""The vzdump options of a job, as pvesh arguments."""
|
||||
args = []
|
||||
for key, value in sorted(job.items()):
|
||||
if key in JOB_ONLY or key == "host_backups" or value in (None, ""):
|
||||
continue
|
||||
if not re.fullmatch(r"[a-z][a-z0-9-]{0,40}", key):
|
||||
continue
|
||||
for item in value if isinstance(value, list) else [value]:
|
||||
args += [f"--{key}", str(_flat(item))]
|
||||
return args
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs/<job_id>/run", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def run_job(job_id):
|
||||
"""Start the backup of a job now. Each node runs its part as a Proxmox
|
||||
task, so it is listed and logged like a scheduled run."""
|
||||
if not ID_RE.match(job_id):
|
||||
return _err("Invalid job")
|
||||
ok, job = _pvesh_json(["get", f"/cluster/backup/{job_id}"])
|
||||
if not ok:
|
||||
return _err(job, 404)
|
||||
job = _normalize_job(job)
|
||||
if not job.get("storage") or not STORAGE_RE.match(str(job["storage"])):
|
||||
return _err("The job has no storage")
|
||||
nodes, error = _run_nodes(job)
|
||||
if error:
|
||||
return _err(error, 500)
|
||||
if not nodes:
|
||||
return _err("No online node has a guest of this job")
|
||||
arguments = _run_arguments(job)
|
||||
tasks, failures = [], []
|
||||
for node in nodes:
|
||||
if not NODE_RE.match(node):
|
||||
continue
|
||||
ok, out = _pvesh(["create", f"/nodes/{node}/vzdump", *arguments])
|
||||
if ok:
|
||||
found = re.search(r"UPID:[^\s\"']+", out)
|
||||
tasks.append({"node": node, "upid": found.group(0) if found else ""})
|
||||
else:
|
||||
failures.append({"node": node, "error": out})
|
||||
if not tasks:
|
||||
return _err(failures[0]["error"] if failures else "The backup could not be started", 500)
|
||||
return jsonify({"success": True, "tasks": tasks, "failures": failures})
|
||||
Reference in New Issue
Block a user