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,69 @@
|
||||
"""The VM/CT backup jobs feature is protected, shipped and translated."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
ROUTES = ROOT / 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
SCRIPT = ROOT / 'scripts/backup_restore/vm_backup_jobs.sh'
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
|
||||
class VmBackupJobsWiring(TestCase):
|
||||
def test_every_route_is_protected_and_every_change_needs_an_administrator(self):
|
||||
source = ROUTES.read_text()
|
||||
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
|
||||
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
|
||||
self.assertEqual(len(routes), 8)
|
||||
for path, method, guard, name in routes:
|
||||
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
|
||||
self.assertEqual(guard, expected, f'{method} {path} ({name})')
|
||||
|
||||
def test_proxmox_is_called_without_a_shell(self):
|
||||
source = ROUTES.read_text()
|
||||
self.assertNotIn('shell=True', source)
|
||||
self.assertNotIn('os.system', source)
|
||||
self.assertEqual(source.count('subprocess.run('), 1)
|
||||
self.assertIn('subprocess.run(["pvesh", *args]', source)
|
||||
|
||||
def test_the_monitor_ships_and_registers_it(self):
|
||||
self.assertIn('flask_vm_backup_routes.py', (ROOT / 'AppImage/scripts/build_appimage.sh').read_text())
|
||||
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
|
||||
self.assertIn('from flask_vm_backup_routes import vm_backup_bp', server)
|
||||
self.assertIn('app.register_blueprint(vm_backup_bp)', server)
|
||||
self.assertIn('<BackupTab ', (ROOT / 'AppImage/components/proxmox-dashboard.tsx').read_text())
|
||||
tab = (ROOT / 'AppImage/components/backup-tab.tsx').read_text()
|
||||
self.assertIn('<HostBackup />', tab)
|
||||
self.assertIn('<VmBackupJobs />', tab)
|
||||
# The backups of the host and those of the guests are separate views.
|
||||
self.assertNotIn('VmBackupJobs', (ROOT / 'AppImage/components/host-backup.tsx').read_text())
|
||||
|
||||
def test_every_text_of_the_card_exists_in_the_eight_languages(self):
|
||||
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
|
||||
used = set(re.findall(r'backup\.vmJobs\.([A-Za-z]+)', component))
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
|
||||
self.assertEqual(used - set(english), set())
|
||||
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
|
||||
for locale in LOCALES:
|
||||
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
|
||||
self.assertEqual(set(texts), set(english), locale)
|
||||
self.assertEqual(set(texts['presets']), set(english['presets']), locale)
|
||||
switch = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['viewSwitch']
|
||||
self.assertEqual(set(switch), {'ariaLabel', 'host', 'guests'}, locale)
|
||||
for key, value in english.items():
|
||||
if isinstance(value, str):
|
||||
self.assertTrue(texts[key].strip(), f'{locale} {key}')
|
||||
self.assertEqual(fields(texts[key]), fields(value), f'{locale} {key}')
|
||||
|
||||
def test_the_edit_dialog_follows_the_edit_mode_contrast_rule(self):
|
||||
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
|
||||
self.assertIn('bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background', component)
|
||||
|
||||
def test_the_menu_runs_a_job_as_a_proxmox_task_and_installs_nothing(self):
|
||||
source = SCRIPT.read_text()
|
||||
self.assertNotIn('apt-get', source)
|
||||
self.assertIn('pvesh create "/nodes/$node/vzdump"', source)
|
||||
self.assertEqual(re.findall(r'^\s*vzdump ', source, re.MULTILINE), [])
|
||||
menu = (ROOT / 'scripts/backup_restore/backup_host.sh').read_text()
|
||||
self.assertIn('bash "$LOCAL_SCRIPTS/backup_restore/vm_backup_jobs.sh"', menu)
|
||||
@@ -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})
|
||||
+88
-1
@@ -13,6 +13,7 @@
|
||||
"(make.log not found — DKMS may have failed before invoking make)": "(make.log no encontrado; es posible que DKMS haya fallado antes de invocar make)",
|
||||
"(need ≥ 1024MB)": "(necesita ≥ 1024 MB)",
|
||||
"(no credentials file — guest mode)": "(sin archivo de credenciales — modo invitado)",
|
||||
"(no guests selected)": "(ninguna VM ni CT seleccionada)",
|
||||
"(none)": "(ninguno)",
|
||||
"(not mounted — will be mounted)": "(no montado - se montará)",
|
||||
"(only on SSD/NVMe) to protect your disk": "(solo en SSD/NVMe) para proteger su disco",
|
||||
@@ -37,6 +38,8 @@
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (por defecto en Proxmox)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx para todos. No se necesita un grupo compartido.",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== ACTUALIZACIÓN PVE COMPLETADA ======",
|
||||
">> Create a new backup job <<": ">> Crear un trabajo de backup nuevo <<",
|
||||
">> Save and return <<": ">> Guardar y volver <<",
|
||||
"A GTK Broadway web UI for libvirt and virt-manager.": "Una interfaz web GTK Broadway para libvirt y virt-manager.",
|
||||
"A Personal Relationship Management tool to help you document your social life.": "Una herramienta de gestión de relaciones personales para documentar tu vida social.",
|
||||
"A VirtIO ISO already exists. Do you want to overwrite it?": "Ya existe una ISO VirtIO. ¿Quieres sobrescribirla?",
|
||||
@@ -320,6 +323,7 @@
|
||||
"All detected GPUs are already assigned to this VM.": "Todas las GPU detectadas ya están asignadas a esta VM.",
|
||||
"All detected controllers/NVMe are already present in the selected VM.": "Todos los controladores/NVMe detectados ya están presentes en la VM seleccionada.",
|
||||
"All disks may already be in use or mounted.": "Es posible que todos los discos ya estén en uso o montados.",
|
||||
"All guests on the cluster": "Todas las VM y CT del clúster",
|
||||
"All images are downloaded and verified first, and native backups are taken with the stack stopped. Contracts are published after the whole set is checked. If a step fails, recovery is attempted where needed; recovery can also fail.": "Primero se descargan y verifican todas las imágenes, y los backups nativos se hacen con la pila detenida. Los contratos se publican después de comprobar el conjunto completo. Si falla un paso, se intenta la recuperación donde haga falta; la recuperación también puede fallar.",
|
||||
"All images are downloaded and verified first, and native backups are taken with the stack stopped. Contracts are published after the whole set is checked. If anything fails, all members are recovered.": "Todas las imágenes se descargan y verifican primero, y los backups nativos se toman con la pila parada. Los contratos se publican después de que se revise todo el conjunto. Si algo falla, todos los miembros se recuperan.",
|
||||
"All images imported and configured successfully": "Todas las imágenes importadas y configuradas correctamente",
|
||||
@@ -527,6 +531,7 @@
|
||||
"Baby Buddy web interface": "Interfaz web de Baby Buddy",
|
||||
"Babybuddy is a buddy for babies! Helps caregivers track sleep, feedings, diaper changes, tummy time and more to learn about and predict baby's needs without (as much) guess work.": "¡Babybuddy es un compañero para bebés! Ayuda a quienes los cuidan a registrar el sueño, las tomas, los cambios de pañal, el tiempo boca abajo y más, para conocer y prever las necesidades del bebé sin (tantas) conjeturas.",
|
||||
"Back": "Volver",
|
||||
"Back to job list": "Volver a la lista de trabajos",
|
||||
"Back to previous menu or Esc + Enter": "Volver al menú anterior o Esc + Enter",
|
||||
"Backed up and cleared": "Guardado en backup y vaciado",
|
||||
"Backend": "Backend",
|
||||
@@ -538,6 +543,8 @@
|
||||
"Backup ID (group name in PBS):": "ID del backup (nombre del grupo en PBS):",
|
||||
"Backup ID for this job:": "ID del backup para este trabajo:",
|
||||
"Backup ID:": "ID del backup:",
|
||||
"Backup Jobs": "Trabajos de backup",
|
||||
"Backup Mode": "Modo de backup",
|
||||
"Backup Preview": "Vista previa del backup",
|
||||
"Backup Recommendation": "Recomendación de backup",
|
||||
"Backup Restored": "Backup restaurado",
|
||||
@@ -556,6 +563,7 @@
|
||||
"Backup host configuration": "Backup de la configuración del host",
|
||||
"Backup includes /etc/zfs/zpool.cache. Restore it (same host detected)?": "El backup incluye /etc/zfs/zpool.cache. ¿Restaurarlo (se detectó el mismo host)?",
|
||||
"Backup information": "Información de backup",
|
||||
"Backup job created successfully.": "Trabajo de backup creado correctamente.",
|
||||
"Backup kept in": "Backup conservado en",
|
||||
"Backup location": "Ubicación del backup",
|
||||
"Backup metadata": "Metadatos de backup",
|
||||
@@ -567,6 +575,7 @@
|
||||
"Backup pools not present on this host:": "Grupos del backup no presentes en este host:",
|
||||
"Backup saved to:": "Backup guardado en:",
|
||||
"Backup scheduler and retention": "Programador de backups y retención",
|
||||
"Backup started on node": "Backup iniciado en el nodo",
|
||||
"Backup to Borg repository": "Backup en Borg",
|
||||
"Backup to Proxmox Backup Server (PBS)": "Backup en Proxmox Backup Server (PBS)",
|
||||
"Backup to a specific directory": "Backup en un directorio específico",
|
||||
@@ -826,6 +835,7 @@
|
||||
"Choose a logo for Fastfetch:": "Elige un logotipo para Fastfetch:",
|
||||
"Choose a recent server:": "Elige un servidor reciente:",
|
||||
"Choose a recovery passphrase (write it down somewhere safe):": "Elija una frase de contraseña de recuperación (escríbala en un lugar seguro):",
|
||||
"Choose a schedule (systemd calendar-event syntax):": "Elija una programación (sintaxis de eventos de calendario de systemd):",
|
||||
"Choose a script to execute:": "Elige un script para ejecutar:",
|
||||
"Choose a server:": "Elige un servidor:",
|
||||
"Choose a share to mount:": "Elige un recurso compartido para montar:",
|
||||
@@ -853,6 +863,7 @@
|
||||
"Choose how to run the script:": "Elige cómo ejecutar el script:",
|
||||
"Choose iperf3 mode:": "Elige el modo iperf3:",
|
||||
"Choose options to configure:": "Elige opciones para configurar:",
|
||||
"Choose specific VMs/CTs": "Elegir VM/CT concretas",
|
||||
"Choose the driver version for Coral M.2:": "Elige la versión del controlador para Coral M.2:",
|
||||
"Choose the filesystem for the new partition:": "Elige el sistema de archivos para la nueva partición:",
|
||||
"Choose the release channel to use:": "Elige el canal de release que quieres usar:",
|
||||
@@ -952,6 +963,7 @@
|
||||
"Compose file of this host": "Archivo Compose de este host",
|
||||
"Compressed size:": "Tamaño comprimido:",
|
||||
"Compressing": "Comprimiendo",
|
||||
"Compression": "Compresión",
|
||||
"Compression Tools": "Herramientas de compresión",
|
||||
"Concise output of logical volumes": "Salida concisa de volúmenes lógicos",
|
||||
"Concise output of physical volumes": "Salida concisa de volúmenes físicos",
|
||||
@@ -1042,6 +1054,7 @@
|
||||
"Confirm Borg passphrase": "Confirmar la frase de contraseña de Borg",
|
||||
"Confirm Borg passphrase:": "Confirmar la contraseña de Borg:",
|
||||
"Confirm Controller + NVMe Assignment": "Confirmar asignación de controlador + NVMe",
|
||||
"Confirm Delete": "Confirmar eliminación",
|
||||
"Confirm Deletion": "Confirmar eliminación",
|
||||
"Confirm Format": "Confirmar formato",
|
||||
"Confirm GPU Switch Mode": "Confirmar el modo de cambio de GPU",
|
||||
@@ -1319,6 +1332,7 @@
|
||||
"Could not read the CUDA compute capability": "No podía leer la capacidad de cálculo de CUDA",
|
||||
"Could not read the NVIDIA driver version": "No podía leer la versión del controlador NVIDIA",
|
||||
"Could not read the host firewall rules": "No se pudieron leer las reglas del cortafuegos del host",
|
||||
"Could not read this job.": "No se pudo leer este trabajo.",
|
||||
"Could not remount automatically. Try manually or check credentials.": "No se pudo volver a montar automáticamente. Pruebe manualmente o verifique las credenciales.",
|
||||
"Could not remove VM automatically. Run manually:": "No se pudo eliminar la VM automáticamente. Ejecutar manualmente:",
|
||||
"Could not remove previous DKMS tree at": "No se pudo eliminar el árbol DKMS anterior en",
|
||||
@@ -1400,6 +1414,7 @@
|
||||
"Creating a backup in": "Creando un backup en",
|
||||
"Creating a backup of": "Crear un backup",
|
||||
"Creating a backup of the container...": "Creando un backup del contenedor...",
|
||||
"Creating backup job...": "Creando el trabajo de backup...",
|
||||
"Creating backup of configuration file...": "Creando backup del archivo de configuración...",
|
||||
"Creating backup of network interfaces configuration...": "Creando backup de la configuración de las interfaces de red...",
|
||||
"Creating compressed archive...": "Creando archivo comprimido...",
|
||||
@@ -1469,6 +1484,7 @@
|
||||
"Currently configured target:": "Destino actualmente configurado:",
|
||||
"Currently mounted:": "Actualmente montado:",
|
||||
"Custom": "Costumbre",
|
||||
"Custom (type it myself)": "Personalizada (escribirla a mano)",
|
||||
"Custom Configuration": "Configuración personalizada",
|
||||
"Custom Content": "Contenido personalizado",
|
||||
"Custom Directory": "Directorio personalizado",
|
||||
@@ -1476,6 +1492,7 @@
|
||||
"Custom MOTD message is already configured": "El mensaje MOTD personalizado ya está configurado",
|
||||
"Custom Options": "Opciones personalizadas",
|
||||
"Custom Path": "Ruta personalizada",
|
||||
"Custom Schedule": "Programación personalizada",
|
||||
"Custom backup paths": "Rutas de backup personalizadas",
|
||||
"Custom backup profile": "Perfil de backup personalizado",
|
||||
"Custom backup to Borg": "Backup personalizado en Borg",
|
||||
@@ -1518,6 +1535,8 @@
|
||||
"DOGWALK is Blender Studio's long awaited second game project, focused on creating a bite-sized interactive storytelling playground. Play as a big adorable dog and explore the winter woods with a little kid.": "DOGWALK es el esperado segundo proyecto de juego de Blender Studio, centrado en crear un pequeño espacio de narración interactiva. Juega como un perro grande y adorable y explora el bosque invernal con un niño.",
|
||||
"DOSBox Staging is a modern continuation of DOSBox a free and open-source emulator that enables the execution of MS-DOS software, especially video games.": "DOSBox Staging es una continuación moderna de DOSBox, un emulador gratuito y de código abierto que permite ejecutar software de MS-DOS, sobre todo videojuegos.",
|
||||
"DVB device directory": "Directorio de dispositivos DVB",
|
||||
"Daily at 02:00": "Cada día a las 02:00",
|
||||
"Daily at 03:00": "Cada día a las 03:00",
|
||||
"Data": "Datos",
|
||||
"Data location": "Ubicación de los datos",
|
||||
"Data size:": "Tamaño de datos:",
|
||||
@@ -1557,18 +1576,21 @@
|
||||
"Default will be used:": "Se utilizará el valor predeterminado:",
|
||||
"Default:": "Predeterminado:",
|
||||
"Default: only what the application needs": "Por defecto: solo lo que la aplicación necesita",
|
||||
"Delete": "Eliminar",
|
||||
"Delete Borg target": "Eliminar destino Borg",
|
||||
"Delete Export": "Eliminar Exportar",
|
||||
"Delete Share": "Eliminar Compartir",
|
||||
"Delete a CT (irreversible). Use the correct <ctid>": "Eliminar un CT (irreversible). Utilice el <ctid> correcto",
|
||||
"Delete a VM (irreversible). Use the correct <vmid>": "Eliminar una VM (irreversible). Utilice el <vmid> correcto",
|
||||
"Delete archive": "Eliminar archivo",
|
||||
"Delete backup job": "¿Eliminar el trabajo de backup",
|
||||
"Delete it?": "¿Eliminarla?",
|
||||
"Delete job": "Eliminar trabajo",
|
||||
"Delete scheduled backup job?": "¿Eliminar el trabajo de backup programado?",
|
||||
"Delete the image to free the space?": "¿Eliminar la imagen para liberar el espacio?",
|
||||
"Delete the images to free the space?": "¿Eliminar las imágenes para liberar el espacio?",
|
||||
"Delete this corrupt archive and pick another": "Eliminar este archivo dañado y elegir otro",
|
||||
"Deleting job...": "Eliminando el trabajo...",
|
||||
"Deluge is a lightweight, Free Software, cross-platform BitTorrent client.": "Deluge es un cliente BitTorrent ligero, multiplataforma y de software libre.",
|
||||
"Dependencies installed successfully": "Dependencias instaladas correctamente",
|
||||
"Deploy with this configuration?": "¿Implementar con esta configuración?",
|
||||
@@ -1666,6 +1688,7 @@
|
||||
"Disable NIC Offloading (Intel e1000e)": "Desactivar offloading de NIC (Intel e1000e)",
|
||||
"Disable autostart on conflicting VM": "Deshabilitar el inicio automático en VM en conflicto",
|
||||
"Disable autostart on one VM to avoid startup conflicts.": "Deshabilite el inicio automático en una VM para evitar conflictos de inicio.",
|
||||
"Disable job": "Desactivar trabajo",
|
||||
"Disable kernel audit messages (optional but recommended):": "Deshabilite los mensajes de auditoría del kernel (opcional pero recomendado):",
|
||||
"Disable onboot on affected VM(s)/CT(s)": "Deshabilitar el arranque en las VM/CT afectadas",
|
||||
"Disable portmapper/rpcbind": "Desactivar portmapper/rpcbind",
|
||||
@@ -1844,6 +1867,8 @@
|
||||
"Ebook and audiobook collection manager for Usenet and BitTorrent users.": "Gestor de colecciones de ebooks y audiolibros para usuarios de Usenet y BitTorrent.",
|
||||
"Eden is an experimental open-source emulator for the Nintendo Switch, built with performance and stability in mind.": "Eden es un emulador experimental y de código abierto de Nintendo Switch, creado pensando en el rendimiento y la estabilidad.",
|
||||
"Edge TPU runtime installed.": "Tiempo de ejecución de Edge TPU instalado.",
|
||||
"Edit": "Editar",
|
||||
"Edit Job": "Editar trabajo",
|
||||
"Edit raw CT configuration file": "Editar archivo de configuración CT sin formato",
|
||||
"Edit raw VM configuration file": "Editar archivo de configuración de VM sin formato",
|
||||
"Edit the VM machine type to q35 and try again.": "Edite el tipo de máquina VM a q35 y vuelva a intentarlo.",
|
||||
@@ -1878,6 +1903,7 @@
|
||||
"Enable an optical drive for MakeMKV": "Activar una unidad óptica para MakeMKV",
|
||||
"Enable auto-sync if /var/log exceeds 90% of its size?": "¿Habilitar la sincronización automática si /var/log excede el 90% de su tamaño?",
|
||||
"Enable fast reboots": "Activar reinicios rápidos",
|
||||
"Enable job": "Activar trabajo",
|
||||
"Enable restart on kernel panic": "Activar reinicio ante kernel panic",
|
||||
"Enable the NVIDIA GPU requested by the image": "Activar la GPU NVIDIA solicitada por la imagen",
|
||||
"Enable the NVIDIA GPU required by Open WebUI CUDA": "Activar la GPU NVIDIA que necesita Open WebUI CUDA",
|
||||
@@ -1926,6 +1952,7 @@
|
||||
"Enter a name for the mount point (used as /mnt/<name>):": "Ingrese un nombre para el punto de montaje (usado como /mnt/<nombre>):",
|
||||
"Enter a name for the new virtual machine:": "Ingrese un nombre para la nueva máquina virtual:",
|
||||
"Enter a number, or write or paste a command:": "Ingrese un número, o escriba o pegue un comando:",
|
||||
"Enter a systemd calendar event, e.g.:": "Introduzca un evento de calendario de systemd, por ejemplo:",
|
||||
"Enter a usable IPv4 address with its prefix, for example": "Introduzca una dirección IPv4 usable con su prefijo, por ejemplo",
|
||||
"Enter backup file (.zst):": "Ingrese el archivo de backup (.zst):",
|
||||
"Enter backup path (.tar.zst):": "Ingrese la ruta de backup (.tar.zst):",
|
||||
@@ -2188,6 +2215,8 @@
|
||||
"Failed to create partition table on disk": "No se pudo crear la tabla de particiones en el disco",
|
||||
"Failed to create partition.": "No se pudo crear la partición.",
|
||||
"Failed to create temporary directory:": "No se pudo crear el directorio temporal:",
|
||||
"Failed to create the job.": "No se pudo crear el trabajo.",
|
||||
"Failed to delete the job.": "No se pudo eliminar el trabajo.",
|
||||
"Failed to destroy LXC:": "No se pudo destruir LXC:",
|
||||
"Failed to destroy VM:": "No se pudo destruir la VM:",
|
||||
"Failed to detect installed NVIDIA driver version.": "No se pudo detectar la versión del controlador NVIDIA instalada.",
|
||||
@@ -2288,6 +2317,7 @@
|
||||
"Failed to update intel-gpu-tools": "No se pudo actualizar Intel-gpu-tools",
|
||||
"Failed to update package list.": "No se pudo actualizar la lista de paquetes.",
|
||||
"Failed to update package lists": "No se pudieron actualizar las listas de paquetes",
|
||||
"Failed to update the job.": "No se pudo actualizar el trabajo.",
|
||||
"Failed to write": "No se pudo escribir",
|
||||
"Failed transitions from D3cold to D0": "Transiciones fallidas de D3cold a D0",
|
||||
"Failed. See log:": "Falló. Ver log:",
|
||||
@@ -2479,6 +2509,7 @@
|
||||
"GRUB_CMDLINE_LINUX_DEFAULT not found in GRUB config": "GRUB_CMDLINE_LINUX_DEFAULT no se encuentra en la configuración de GRUB",
|
||||
"GUI mode (if available)": "Modo GUI (si está disponible)",
|
||||
"GZDoom is a feature centric port for all Doom engine games, based on ZDoom, adding an OpenGL renderer and powerful scripting capabilities.": "GZDoom es un port centrado en funciones para todos los juegos del motor Doom, basado en ZDoom, que añade un renderizador OpenGL y potentes capacidades de scripting.",
|
||||
"GZIP - slower, widely compatible": "GZIP - más lento, muy compatible",
|
||||
"Gaming & Leisure": "Juegos y ocio",
|
||||
"Gateway is not installed.": "La puerta de enlace no está instalada.",
|
||||
"Gateway removed.": "Puerta de enlace eliminada.",
|
||||
@@ -2547,6 +2578,7 @@
|
||||
"Guest share access tests failed:": "Las pruebas de acceso de invitado a los recursos compartidos fallaron:",
|
||||
"Guest share listing failed.": "No se pudieron listar los recursos compartidos como invitado.",
|
||||
"Guest share listing successful": "Recursos compartidos listados como invitado correctamente",
|
||||
"Guests": "VM y CT",
|
||||
"Guided Cleanup Available": "Limpieza guiada disponible",
|
||||
"Guided Repair Available": "Reparación guiada disponible",
|
||||
"HA groups will be migrated to HA rules automatically": "Los grupos de HA se migrarán a reglas de HA automáticamente",
|
||||
@@ -3247,12 +3279,18 @@
|
||||
"Jellyseerr is a free and open source software application for managing requests for your media library.": "Jellyseerr es una aplicación gratuita y de código abierto para gestionar las solicitudes de tu biblioteca multimedia.",
|
||||
"Jenkins Continuous Integration and Delivery server.": "Servidor de integración y entrega continuas Jenkins.",
|
||||
"Jenkins unlock": "Desbloqueo de Jenkins",
|
||||
"Job": "Trabajo",
|
||||
"Job ID (letters, numbers, - _)": "ID de trabajo (letras, números, - _)",
|
||||
"Job ID (optional)": "ID del trabajo (opcional)",
|
||||
"Job ID:": "Identificación del trabajo:",
|
||||
"Job deleted.": "Trabajo eliminado.",
|
||||
"Job deleted:": "Trabajo eliminado:",
|
||||
"Job disabled.": "Trabajo desactivado.",
|
||||
"Job disabled:": "Trabajo deshabilitado:",
|
||||
"Job enabled.": "Trabajo activado.",
|
||||
"Job enabled:": "Trabajo habilitado:",
|
||||
"Job selection returned empty id — aborting.": "La selección de trabajo devolvió una identificación vacía: abortando.",
|
||||
"Job updated successfully.": "Trabajo actualizado correctamente.",
|
||||
"Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks.": "Joplin es una aplicación gratuita y de código abierto de notas y tareas, capaz de manejar un gran número de notas organizadas en cuadernos.",
|
||||
"Journald configuration adjusted to": "Configuración de diario ajustada a",
|
||||
"Journald configuration is already optimized": "La configuración de Journald ya está optimizada",
|
||||
@@ -3336,6 +3374,7 @@
|
||||
"LXC stopped": "LXC se detuvo",
|
||||
"LXC update skipped by user.": "Actualización de LXC omitida por el usuario.",
|
||||
"LXCs to destroy:": "LXC para destruir:",
|
||||
"LZO - fastest, weaker ratio": "LZO - el más rápido, menor compresión",
|
||||
"Lab interruption after installing the new container": "Interrupción de laboratorio después de instalar el nuevo contenedor",
|
||||
"Lab interruption after protecting the data": "Interrupción de laboratorio después de proteger los datos",
|
||||
"Label Studio is an open source data labeling tool. It lets you label data types like audio, text, images, videos, and time series with a simple and straightforward UI and export to various model formats. It can be used to prepare raw data or improve existing training data to get more accurate ML models.": "Label Studio es una herramienta de etiquetado de datos de código abierto. Permite etiquetar audio, texto, imágenes, vídeos y series temporales con una interfaz sencilla y exportar a varios formatos de modelo. Sirve para preparar datos en bruto o mejorar datos de entrenamiento existentes y obtener modelos de ML más precisos.",
|
||||
@@ -3350,6 +3389,8 @@
|
||||
"Launching GPU passthrough assistant for VM": "Iniciando el asistente de passthrough de GPU para la VM",
|
||||
"Lazylibrarian is a program to follow authors and grab metadata for all your digital reading needs. It uses a combination of Goodreads Librarything and optionally GoogleBooks as sources for author info and book info. This container is based on the DobyTang fork.": "Lazylibrarian es un programa para seguir autores y obtener metadatos para todas tus lecturas digitales. Combina Goodreads, Librarything y, opcionalmente, GoogleBooks como fuentes de información de autores y libros. Este contenedor se basa en el fork de DobyTang.",
|
||||
"Ldap-auth software is for authenticating users who request protected resources from servers proxied by nginx. It includes a daemon (ldap-auth) that communicates with an authentication server, and a webserver daemon that generates an authentication cookie based on the user’s credentials. The daemons are written in Python for use with a Lightweight Directory Access Protocol (LDAP) authentication server (OpenLDAP or Microsoft Windows Active Directory 2003 and 2012).": "Ldap-auth autentica a los usuarios que piden recursos protegidos en servidores tras un proxy nginx. Incluye un daemon (ldap-auth) que se comunica con un servidor de autenticación y un daemon de servidor web que genera una cookie de autenticación a partir de las credenciales del usuario. Los daemons están escritos en Python para usarse con un servidor de autenticación LDAP (OpenLDAP o Microsoft Windows Active Directory 2003 y 2012).",
|
||||
"Leave empty to auto-generate one.": "Déjelo vacío para generarlo automáticamente.",
|
||||
"Leave empty to keep every backup (not recommended).": "Déjelo vacío para conservar todos los backups (no recomendado).",
|
||||
"Legacy PVE 8 .list files commented or not present": "Archivos .list de PVE 8 heredados comentados o no presentes",
|
||||
"Legacy ceph.list commented or not present": "Legacy ceph.list comentado o no presente",
|
||||
"Legacy gasket-dkms cleanup could not be verified as complete.": "No se ha podido verificar que la limpieza del paquete heredado gasket-dkms haya finalizado correctamente.",
|
||||
@@ -3509,6 +3550,7 @@
|
||||
"Mapped GID on host": "GID asignado en el host",
|
||||
"Mapped UID on host": "UID asignado en el host",
|
||||
"Mariadb is one of the most popular database servers. Made by the original developers of MySQL.": "Mariadb es uno de los servidores de bases de datos más populares, creado por los desarrolladores originales de MySQL.",
|
||||
"Mark the VMs/CTs to include in this job:": "Marque las VM/CT que incluirá este trabajo:",
|
||||
"Mastodon is a free, open-source social network server based on ActivityPub where users can follow friends and discover new ones..": "Mastodon es un servidor de red social gratuito y de código abierto basado en ActivityPub, donde los usuarios pueden seguir a sus amigos y descubrir otros nuevos..",
|
||||
"Max FD limit / ulimit configured": "Límite máximo de FD/ulimit configurado",
|
||||
"Max FS open files configuration created successfully": "Configuración de archivos abiertos de Max FS creada con éxito",
|
||||
@@ -3973,6 +4015,7 @@
|
||||
"No VMs available in the system.": "No hay máquinas virtuales disponibles en el sistema.",
|
||||
"No VMs available on this host.": "No hay máquinas virtuales disponibles en este host.",
|
||||
"No VMs found": "No se encontraron máquinas virtuales",
|
||||
"No VMs or CTs found on this cluster.": "No se encontraron VM ni CT en este clúster.",
|
||||
"No Valid Servers": "No hay servidores válidos",
|
||||
"No VirtIO ISO found. Please download one.": "No se encontró ningún ISO de VirtIO. Descargue uno.",
|
||||
"No VirtIO ISO selected. Please choose again.": "No se seleccionó ningún ISO de VirtIO. Por favor elige de nuevo.",
|
||||
@@ -4050,6 +4093,7 @@
|
||||
"No free ProxMenux private /24 network is available": "No hay ninguna red privada /24 de ProxMenux libre",
|
||||
"No gasket DKMS registrations remain.": "No quedan registros de gasket en DKMS.",
|
||||
"No group creation required — uses world-writable sticky bit permissions.": "No se requiere creación de grupos: utiliza permisos de bits adhesivos de escritura mundial.",
|
||||
"No guest selected.": "No se ha seleccionado ninguna VM ni CT.",
|
||||
"No host VFIO reconfiguration expected": "No se espera reconfiguración del VFIO del host",
|
||||
"No host VFIO/native binding changes were required.": "No se requirieron cambios de enlace nativo/VFIO del host.",
|
||||
"No host backups were found in this PBS repository:": "No se encontraron backups del host en este repositorio de PBS:",
|
||||
@@ -4081,6 +4125,7 @@
|
||||
"No network configuration backups found.": "No se encontraron backups de la configuración de red.",
|
||||
"No network interfaces configured (besides loopback)": "No hay interfaces de red configuradas (aparte del loopback)",
|
||||
"No new Controller/NVMe entries were added.": "No se agregaron nuevas entradas de Controlador/NVMe.",
|
||||
"No online node has a guest of this job.": "Ningún nodo en línea tiene una VM o CT de este trabajo.",
|
||||
"No optimizations detected to uninstall.": "No se detectaron optimizaciones para desinstalar.",
|
||||
"No partitions found on disk. Nothing was wiped.": "No se encontraron particiones en el disco. No se borró nada.",
|
||||
"No password provided.": "No se proporcionó contraseña.",
|
||||
@@ -4120,6 +4165,7 @@
|
||||
"No storage": "Sin almacenamiento",
|
||||
"No storage volumes available for VM images.": "No hay volúmenes de almacenamiento disponibles para imágenes de VM.",
|
||||
"No storage volumes available for disk images.": "No hay volúmenes de almacenamiento disponibles para imágenes de disco.",
|
||||
"No storage with 'backup' content enabled was found.\\n\\nAdd one from Datacenter > Storage first (PBS, or a directory/NFS with the Backup content type).": "No se encontró ningún almacenamiento con el contenido 'backup' habilitado.\\n\\nAñada uno antes desde Datacenter > Storage (PBS, o un directorio/NFS con el tipo de contenido Backup).",
|
||||
"No stored credentials found.": "No se encontraron credenciales almacenadas.",
|
||||
"No suitable disks found.": "No se encontraron discos adecuados.",
|
||||
"No supported NVIDIA versions found for this GPU": "No se encontraron versiones de NVIDIA compatibles para esta GPU",
|
||||
@@ -4173,6 +4219,9 @@
|
||||
"Note: A system reboot will be required after enabling IOMMU.": "Nota: Será necesario reiniciar el sistema después de habilitar IOMMU.",
|
||||
"Note: this only works if the NFS server does NOT use 'all_squash' for root.": "Nota: esto solo funciona si el servidor NFS NO usa 'all_squash' como root.",
|
||||
"Notes": "Notas",
|
||||
"Notes Template": "Plantilla de notas",
|
||||
"Notes template": "Plantilla de notas",
|
||||
"Notes template shown next to each backup (optional).": "Plantilla de las notas que se muestran junto a cada backup (opcional).",
|
||||
"Nothing has been changed and the current driver is untouched. It will not be offered again while this kernel is running.": "No se ha cambiado nada y el controlador actual no se ha modificado. No se volverá a ofrecer mientras este kernel esté en ejecución.",
|
||||
"Nothing inside the container is modified": "No se modifica nada dentro del contenedor",
|
||||
"Nothing selectable": "Nada seleccionable",
|
||||
@@ -4495,6 +4544,7 @@
|
||||
"Pidgin is a chat program which lets you log into accounts on multiple chat networks simultaneously. This means that you can be chatting with friends on XMPP and sitting in an IRC channel at the same time.": "Pidgin es un programa de chat que permite iniciar sesión a la vez en cuentas de varias redes de chat. Así puedes hablar con amigos por XMPP y estar en un canal IRC al mismo tiempo.",
|
||||
"Piper is a fast, local neural text to speech system that sounds great and is optimized for the Raspberry Pi 4. This container provides a Wyoming protocol server for Piper.": "Piper es un sistema neuronal de texto a voz rápido y local, que suena muy bien y está optimizado para la Raspberry Pi 4. Este contenedor ofrece un servidor del protocolo Wyoming para Piper.",
|
||||
"Piwigo is a photo gallery software for the web that comes with powerful features to publish and manage your collection of pictures.": "Piwigo es un software de galería de fotos para la web, con potentes funciones para publicar y gestionar tu colección de imágenes.",
|
||||
"Placeholders: {{guestname}} {{node}} {{vmid}}": "Marcadores: {{guestname}} {{node}} {{vmid}}",
|
||||
"Planka is an elegant open source project tracking tool.": "Planka es una elegante herramienta de seguimiento de proyectos de código abierto.",
|
||||
"Please check network connectivity.": "Por favor verifique la conectividad de la red.",
|
||||
"Please check permissions and try again.": "Por favor verifique los permisos e inténtelo nuevamente.",
|
||||
@@ -4578,6 +4628,7 @@
|
||||
"Press Enter to return...": "Presione Entrar para regresar...",
|
||||
"Press Enter when the line has been pasted on the server...": "Presione Entrar cuando la línea se haya pegado en el servidor...",
|
||||
"Press OK to see the preview, then confirm": "Presione OK para ver la vista previa, luego confirme",
|
||||
"Press any key to continue...": "Pulse cualquier tecla para continuar...",
|
||||
"Pretty uptime format": "Formato bastante tiempo de actividad",
|
||||
"Preview Backup": "Vista previa de backup",
|
||||
"Preview Changes": "Vista previa de cambios",
|
||||
@@ -5076,6 +5127,7 @@
|
||||
"Results will be saved automatically to:": "Los resultados se guardarán automáticamente en:",
|
||||
"Results will be saved to:": "Los resultados se guardarán en:",
|
||||
"Retention": "Retención",
|
||||
"Retention policy (prune-backups syntax).": "Política de retención (sintaxis de prune-backups).",
|
||||
"RetroArch is a frontend for emulators, game engines and media players. It enables you to run classic games on a wide range of computers and consoles through its slick graphical interface.": "RetroArch es un frontend para emuladores, motores de juego y reproductores multimedia. Permite ejecutar juegos clásicos en una gran variedad de ordenadores y consolas con una interfaz gráfica muy cuidada.",
|
||||
"Return": "Volver",
|
||||
"Return to Main Menu": "Volver al menú principal",
|
||||
@@ -5111,6 +5163,7 @@
|
||||
"Run 'Mount NFS Share' to install NFS client automatically.": "Ejecute «Montar recurso compartido NFS» para instalar automáticamente el cliente NFS.",
|
||||
"Run 'Mount Samba Share' to install CIFS client automatically.": "Ejecute «Montar recurso compartido Samba» para instalar automáticamente el cliente CIFS.",
|
||||
"Run GGUF LLMs locally with GPU acceleration": "Ejecuta LLM en formato GGUF en local con aceleración por GPU",
|
||||
"Run Now": "Ejecutar ahora",
|
||||
"Run PVE 8 to 9": "Ejecuta PVE 8 a 9",
|
||||
"Run PVE 8 to 9 check": "Ejecute la verificación PVE 8 a 9",
|
||||
"Run \\\"Install NVIDIA Drivers on Host\\\" first so the installer is cached.": "Primero ejecute \\\"Instalar controladores NVIDIA en el host\\\" para que el instalador se almacene en caché.",
|
||||
@@ -5121,11 +5174,13 @@
|
||||
"Run as server or client? [s/c]:": "¿Ejecutar como servidor o cliente? [Carolina del Sur]:",
|
||||
"Run checklist again to verify upgrade:": "Ejecute la lista de verificación nuevamente para verificar la actualización:",
|
||||
"Run from console, or SSH inside tmux/screen": "Ejecutar desde la consola o SSH dentro de tmux/screen",
|
||||
"Run job": "¿Ejecutar el trabajo",
|
||||
"Run job now": "Ejecutar trabajo ahora",
|
||||
"Run mode selected: Automatic": "Modo de ejecución seleccionado: Automático",
|
||||
"Run mode selected: Automatic/Unattended": "Modo de ejecución seleccionado: Automático/Desatendido",
|
||||
"Run mode selected: Interactive": "Modo de ejecución seleccionado: Interactivo",
|
||||
"Run mode: Unattended": "Modo de ejecución: desatendido",
|
||||
"Run now": "Ejecutar ahora",
|
||||
"Run security audit now": "Ejecute la auditoría de seguridad ahora",
|
||||
"Run the following inside the VM:": "Ejecute lo siguiente dentro de la VM:",
|
||||
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Ejecute el cambio de canal de release desde una sesión SSH o la consola del host Proxmox con:",
|
||||
@@ -5234,6 +5289,7 @@
|
||||
"Save the passphrase somewhere safe NOW, before continuing.": "Guarde la frase de contraseña en un lugar seguro AHORA, antes de continuar.",
|
||||
"Save this Borg target so you don't need to enter the details again?": "¿Guardar este destino Borg para no tener que volver a introducir los detalles?",
|
||||
"Saved record removed": "Registro guardado eliminado",
|
||||
"Saving changes...": "Guardando los cambios...",
|
||||
"Saving the new configuration": "Salvando la nueva configuración",
|
||||
"Saving the new configuration of the application...": "Guardando la nueva configuración de la aplicación...",
|
||||
"Saving the new configuration...": "Guardando la nueva configuración...",
|
||||
@@ -5243,7 +5299,7 @@
|
||||
"Scanning available physical disks...": "Escaneando discos físicos disponibles...",
|
||||
"Scanning network for NFS servers...": "Escaneando la red en busca de servidores NFS...",
|
||||
"Scanning network for Samba servers...": "Escaneando red en busca de servidores Samba...",
|
||||
"Schedule": "Cronograma",
|
||||
"Schedule": "Programación",
|
||||
"Schedule of the automatic restart, in cron format (for example 0 6 * * * for every day at 6:00)": "Programación del reinicio automático, en formato cron (por ejemplo 0 6 * * * para todos los días a las 6:00)",
|
||||
"Schedule:": "Cronograma:",
|
||||
"Scheduled backup job created": "Trabajo de backup programado creado",
|
||||
@@ -5291,6 +5347,7 @@
|
||||
"Select Folder": "Seleccionar carpeta",
|
||||
"Select GPU for VM Passthrough": "Seleccione la GPU para el passthrough a VM",
|
||||
"Select GPU(s)": "Seleccionar GPU(s)",
|
||||
"Select Guests": "Seleccionar VM y CT",
|
||||
"Select Host Directory": "Seleccionar directorio de host",
|
||||
"Select Host Folder": "Seleccionar carpeta de host",
|
||||
"Select LXC Container": "Seleccione el contenedor LXC",
|
||||
@@ -5319,6 +5376,7 @@
|
||||
"Select a category or search for applications:": "Seleccione una categoría o busque aplicaciones:",
|
||||
"Select a category or search for scripts:": "Seleccione una categoría o busque scripts:",
|
||||
"Select a custom ISO to use:": "Seleccione una ISO personalizada para usar:",
|
||||
"Select a job to view/edit, or create a new one:": "Seleccione un trabajo para verlo o editarlo, o cree uno nuevo:",
|
||||
"Select a job:": "Seleccione un trabajo:",
|
||||
"Select a network management option:": "Seleccione una opción de gestión de red:",
|
||||
"Select a new language for the menu:": "Seleccione un nuevo idioma para el menú:",
|
||||
@@ -5350,6 +5408,7 @@
|
||||
"Select backup to restore": "Seleccione el backup para restaurar",
|
||||
"Select backup to restore:": "Seleccione el backup para restaurar:",
|
||||
"Select bridge for network interface(s):": "Seleccione el puente para las interfaces de red:",
|
||||
"Select compression:": "Seleccione la compresión:",
|
||||
"Select container:": "Seleccionar contenedor:",
|
||||
"Select content types for this storage:": "Seleccione tipos de contenido para este almacenamiento:",
|
||||
"Select conversion guide:": "Seleccione la guía de conversión:",
|
||||
@@ -5401,6 +5460,7 @@
|
||||
"Select the VM to add the GPU to:": "Seleccione la VM a la que agregar la GPU:",
|
||||
"Select the VM to which you want to add disks:": "Seleccione la VM a la que desea agregar discos:",
|
||||
"Select the VM where you want to import the disk image:": "Seleccione la VM donde desea importar la imagen del disco:",
|
||||
"Select the backup mode:": "Seleccione el modo de backup:",
|
||||
"Select the bus type for the disks:": "Seleccione el tipo de bus para los discos:",
|
||||
"Select the directory containing disk images:": "Seleccione el directorio que contiene las imágenes de disco:",
|
||||
"Select the directory to bind to container:": "Seleccione el directorio para vincular al contenedor:",
|
||||
@@ -5421,6 +5481,7 @@
|
||||
"Select the parent job to attach to:": "Seleccione el trabajo principal al que adjuntarlo:",
|
||||
"Select the paths to restore (one per backup entry):": "Seleccione las rutas para restaurar (una por entrada de backup):",
|
||||
"Select the privileged LXC container to convert:": "Seleccione el contenedor LXC privilegiado para convertir:",
|
||||
"Select the storage that will receive the backups:": "Seleccione el almacenamiento que recibirá los backups:",
|
||||
"Select the storage volume for disk import:": "Seleccione el volumen de almacenamiento para la importación de disco:",
|
||||
"Select the sync interval (in hours):": "Seleccione el intervalo de sincronización (en horas):",
|
||||
"Select the target VM for PCI passthrough:": "Seleccione la VM de destino para el passthrough PCI:",
|
||||
@@ -5547,6 +5608,7 @@
|
||||
"Shares found:": "Recursos compartidos encontrados:",
|
||||
"Shares selected from guest listing:": "Recursos compartidos seleccionados de la lista de invitado:",
|
||||
"Shell user ulimit set": "Conjunto de ulimit de usuario de Shell",
|
||||
"Short name for this job (letters, numbers, hyphens).": "Nombre corto para este trabajo (letras, números y guiones).",
|
||||
"Short self-test started on": "Autotest breve iniciado el",
|
||||
"Short test — ~2 minutes, basic surface check": "Prueba corta — ~2 minutos, comprobación básica",
|
||||
"Shotcut is a free, open source, cross-platform video editor.": "Shotcut es un editor de vídeo gratuito, de código abierto y multiplataforma.",
|
||||
@@ -5630,6 +5692,7 @@
|
||||
"Smart restore plan — hardware compatibility check": "Plan de restauración inteligente: verificación de compatibilidad de hardware",
|
||||
"Smokeping keeps track of your network latency. For a full example of what this application is capable of visit UCDavis.": "Smokeping hace un seguimiento de la latencia de tu red. Para ver un ejemplo completo de lo que puede hacer esta aplicación, visita UCDavis.",
|
||||
"SnapOtter": "SnapOtter",
|
||||
"Snapshot - no downtime (recommended)": "Snapshot - sin parada (recomendado)",
|
||||
"Snippets — hook scripts / config": "Snippets — hook scripts / config",
|
||||
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrada en SoC: estrecho acoplamiento con otros componentes de SoC",
|
||||
"Some DKMS removals reported errors; final verification will determine the result.": "Algunas eliminaciones de DKMS han devuelto errores; la verificación final determinará el resultado.",
|
||||
@@ -5737,6 +5800,7 @@
|
||||
"Starting installer...": "Iniciando el instalador...",
|
||||
"Starting privileged container...": "Iniciando el contenedor privilegiado...",
|
||||
"Starting rpcbind service...": "Iniciando el servicio rpcbind...",
|
||||
"Starting the backup...": "Iniciando el backup...",
|
||||
"Starting the container...": "Iniciando el contenedor...",
|
||||
"Starting the main container and its dependencies...": "Iniciando el contenedor principal y sus dependencias...",
|
||||
"Starting the service:": "Iniciando el servicio:",
|
||||
@@ -5755,6 +5819,7 @@
|
||||
"Step 2: Testing actual share access with guest...": "Paso 2: probando el acceso real al recurso compartido como invitado...",
|
||||
"Steps that will run:": "Pasos que se ejecutarán:",
|
||||
"Still has issues": "Todavía tiene problemas",
|
||||
"Stop - shuts the guest down during backup": "Stop - apaga la VM o CT durante el backup",
|
||||
"Stop it first and run this option again.": "Deténgalo primero y ejecute esta opción nuevamente.",
|
||||
"Stop that VM, then choose:": "Detenga ese VM, luego elija:",
|
||||
"Stop the CT, unmount the disk on the HOST, and remount with:": "Detenga el CT, desmonte el disco en el HOST y vuelva a montarlo con:",
|
||||
@@ -5827,6 +5892,7 @@
|
||||
"Supervisor does not confirm healthy and supported yet": "Supervisor no confirma sano y apoyado aún",
|
||||
"Supervisor reports no connectivity; retrying to get versions and install components": "Supervisor no informa de conectividad; reintentar para obtener versiones e instalar componentes",
|
||||
"Supported formats: .img, .qcow2, .vmdk, .raw": "Formatos admitidos: .img, .qcow2, .vmdk, .raw",
|
||||
"Suspend - pauses the guest during backup": "Suspend - pausa la VM o CT durante el backup",
|
||||
"Swap": "Swap",
|
||||
"Swap in MB": "Swap en MB",
|
||||
"Swap partition detected": "Partición swap detectada",
|
||||
@@ -5887,6 +5953,7 @@
|
||||
"Tandoor needs at least 1 GB for staticfiles and 4 GB for PostgreSQL": "Tandoor necesita al menos 1 GB para ficheros estáticos y 4 GB para PostgreSQL",
|
||||
"Tandoor needs to complete its first start to create the initial administrator": "Tandoor necesita completar su primer arranque para crear el administrador inicial",
|
||||
"Target IQN:": "IQN destino:",
|
||||
"Target Storage": "Almacenamiento de destino",
|
||||
"Target VM": "Máquina virtual de destino",
|
||||
"Target VM validated": "VM de destino validada",
|
||||
"Target channel:": "Canal destino:",
|
||||
@@ -6031,9 +6098,11 @@
|
||||
"The archive could not be extracted.": "No se pudo extraer el archivo.",
|
||||
"The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "El directorio de destino del archivo está DENTRO de una de las rutas de las que va a hacer backup. Escribir el archivo allí copiaría el backup dentro de sí mismo, lo que produciría un archivo dañado o que crecería sin límite hasta llenar el disco.",
|
||||
"The backup could not be identified; the image is not replaced": "El backup no se pudo identificar; la imagen no es reemplazada",
|
||||
"The backup could not be started on node": "No se pudo iniciar el backup en el nodo",
|
||||
"The backup did not pass its integrity check; creating it again...": "El backup no superó la comprobación de integridad; creándolo de nuevo...",
|
||||
"The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "Los metadatos del backup se compararon con este host. Los siguientes elementos se OMITIRÁN para mantener el arranque seguro:",
|
||||
"The backup of a member could not be identified": "No se pudo identificar el backup de un miembro",
|
||||
"The backup runs as a Proxmox task; follow it in the task log of Proxmox.": "El backup se ejecuta como tarea de Proxmox; sígalo en el registro de tareas de Proxmox.",
|
||||
"The backup storage does not exist:": "El almacenamiento de backups no existe:",
|
||||
"The backup was altered; recovery blocked": "El backup fue alterado; la recuperación bloqueada",
|
||||
"The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "El backup se hizo en un PVE o kernel major.minor distinto. Estas rutas se OMITIRÁN para mantener el arranque seguro:",
|
||||
@@ -6226,6 +6295,7 @@
|
||||
"The manifest does not match its digest": "El manifiesto no coincide con su digestión",
|
||||
"The member journal belongs to another stack operation": "El diario del miembro pertenece a otra operación de pila",
|
||||
"The member journal is outside the registry": "El diario del miembro está fuera del registro",
|
||||
"The mode of this job is stop: its guests are shut down during the backup.": "El modo de este trabajo es stop: sus VM y CT se apagan durante el backup.",
|
||||
"The mount evidence does not match the verified directories": "La evidencia de montaje no coincide con los directorios verificados",
|
||||
"The mount source or options were not kept": "La fuente de montaje o las opciones no se guardaron",
|
||||
"The mounted source differs from the configured directory": "La fuente montada difiere del directorio configurado",
|
||||
@@ -6453,6 +6523,8 @@
|
||||
"These are the changes that will be made": "Estos son los cambios que se harán",
|
||||
"These container mount paths have an extension-like suffix:": "Estas rutas de montaje del contenedor tienen un sufijo parecido a una extensión:",
|
||||
"These containers were restored from a backup and this host has no record of their OCI application:": "Estos contenedores se restauraron desde un backup y este host no tiene el registro de su aplicación OCI:",
|
||||
"These host backups run with this job and expect its storage:": "Estos backups del host se ejecutan con este trabajo y esperan su almacenamiento:",
|
||||
"These host backups run with this job and will stop running:": "Estos backups del host se ejecutan con este trabajo y dejarán de ejecutarse:",
|
||||
"These interface configurations will be removed": "Estas configuraciones de interfaz serán eliminadas",
|
||||
"These manual changes cannot be adopted safely:": "Estos cambios manuales no se pueden incorporar con seguridad:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Estas rutas no se restaurarán en vivo y se extraerán para su recuperación manual.",
|
||||
@@ -6514,6 +6586,7 @@
|
||||
"This means the credentials are incorrect.": "Esto significa que las credenciales son incorrectas.",
|
||||
"This might indicate network connectivity issues.": "Esto podría indicar problemas de conectividad de red.",
|
||||
"This monitor uses a privileged LXC, shares processes and network with Proxmox and disables AppArmor in the CT. It uses the IP address and firewall of the host. A compromised image could affect the host; do not expose its web UI to the Internet.": "Este monitor utiliza un LXC privilegiado, comparte procesos y red con Proxmox y deshabilita AppArmor en el CT. Utiliza la dirección IP y el cortafuegos del host. Una imagen comprometida podría afectar al host; no exponga su interfaz de usuario web a Internet.",
|
||||
"This only removes the schedule. Existing backup archives are NOT deleted.": "Solo se elimina la programación. Los backups ya hechos NO se eliminan.",
|
||||
"This operation may take several minutes and requires internet connectivity.": "Esta operación puede tardar varios minutos y requiere conexión a Internet.",
|
||||
"This package was installed by older versions of the ProxMenux Coral installer that placed the M.2 kernel driver on every system, including USB-only setups. It is not needed for Coral USB devices, which use libedgetpu1-std / libedgetpu1-max only.": "Este paquete fue instalado por versiones antiguas del instalador de Coral de ProxMenux, que instalaban el controlador del kernel para Coral M.2 en todos los sistemas, incluidos aquellos que solo usaban Coral USB. No es necesario para los dispositivos Coral USB, que únicamente utilizan libedgetpu1-std o libedgetpu1-max.",
|
||||
"This passphrase is the ONLY way to access encrypted Borg backups.": "Esta frase de contraseña es la ÚNICA forma de acceder a los backups cifrados de Borg.",
|
||||
@@ -6837,6 +6910,7 @@
|
||||
"Updating initramfs for all kernels...": "Actualizando initramfs para todos los kernels...",
|
||||
"Updating initramfs so the ARC cap applies at next boot...": "Actualizando initramfs para que el límite ARC se aplique en el próximo arranque...",
|
||||
"Updating initramfs, GRUB, and EFI boot, patience...": "Actualizando initramfs, GRUB y arranque EFI, paciencia...",
|
||||
"Updating job status...": "Actualizando el estado del trabajo...",
|
||||
"Updating journald to store info-level messages...": "Actualizando el diario para almacenar mensajes a nivel de información...",
|
||||
"Updating kernel panic configuration...": "Actualizando la configuración de pánico del kernel...",
|
||||
"Updating package lists...": "Actualizando listas de paquetes...",
|
||||
@@ -6951,6 +7025,7 @@
|
||||
"VM Name": "Nombre de la máquina virtual",
|
||||
"VM Storage (images, backup)": "Discos de VM (images, backup)",
|
||||
"VM and CT Management Commands": "Comandos de gestión de VM y CT",
|
||||
"VM and CT backup jobs (Datacenter > Backup)": "Trabajos de backup de VM y CT (Datacenter > Backup)",
|
||||
"VM creation cancelled before disk planning.": "La creación de VM se canceló antes de la planificación del disco.",
|
||||
"VM creation cancelled by user.": "Creación de VM cancelada por el usuario.",
|
||||
"VM creation completed": "Creación de VM completada",
|
||||
@@ -6969,6 +7044,7 @@
|
||||
"VM shell created:": "Shell de VM creado:",
|
||||
"VM started": "VM iniciada",
|
||||
"VM stopped": "VM detenida",
|
||||
"VM/CT Backup Jobs (Datacenter > Backup)": "Trabajos de backup de VM/CT (Datacenter > Backup)",
|
||||
"VM:": "Máquina virtual:",
|
||||
"VMID (empty = next free)": "VMID (vacío = siguiente libre)",
|
||||
"VMID in use": "VMID en uso",
|
||||
@@ -7119,8 +7195,10 @@
|
||||
"Website": "Sitio web",
|
||||
"Webstation is a web native emulation focused LXQt desktop based on Ubuntu.": "Webstation es un escritorio LXQt nativo web, centrado en la emulación y basado en Ubuntu.",
|
||||
"Webtop - Alpine, Ubuntu, Fedora, and Arch based containers containing full desktop environments in officially supported flavors accessible via any modern web browser.": "Webtop - Contenedores basados en Alpine, Ubuntu, Fedora y Arch con entornos de escritorio completos, en variantes con soporte oficial, accesibles desde cualquier navegador web moderno.",
|
||||
"Weekly, Sunday 02:00": "Semanal, domingo a las 02:00",
|
||||
"Weixin (WeChat) is an instant messaging, social media, and mobile payment app developed by Tencent.": "Weixin (WeChat) es una aplicación de mensajería instantánea, redes sociales y pago móvil desarrollada por Tencent.",
|
||||
"What cannot be translated:": "Lo que no puede traducirse:",
|
||||
"What do you want to change?": "¿Qué desea cambiar?",
|
||||
"What do you want to do?": "¿Qué es lo que quieres hacer?",
|
||||
"What runs the recognition of Immich": "Qué ejecuta el reconocimiento de Immich",
|
||||
"What to recreate": "Qué recrear",
|
||||
@@ -7139,6 +7217,7 @@
|
||||
"Where to store the Nextcloud files, configuration and data": "Dónde almacenar los archivos, la configuración y los datos de Nextcloud",
|
||||
"Where to store the consume and export folders": "Dónde almacenar las carpetas de consumo y exportación",
|
||||
"Where to store the recipe images and files": "Dónde almacenar las imágenes y archivos de las recetas",
|
||||
"Which guests should this job back up?": "¿Qué debe incluir este trabajo?",
|
||||
"While the server allows guest listing, no shares are actually accessible without authentication.": "Aunque el servidor permite listar como invitado, en realidad ningún recurso compartido es accesible sin autenticación.",
|
||||
"Wikijs A modern, lightweight and powerful wiki app built on NodeJS.": "Wikijs, una aplicación wiki moderna, ligera y potente construida sobre NodeJS.",
|
||||
"Will be configured now": "Se configurará ahora",
|
||||
@@ -7155,6 +7234,7 @@
|
||||
"Wired NICs in backup missing on target:": "NIC cableadas del backup que faltan en el destino:",
|
||||
"Wireless Controller": "Controlador inalámbrico",
|
||||
"Wireshark is the world’s foremost and widely-used network protocol analyzer. It lets you see what’s happening on your network at a microscopic level and is the de facto (and often de jure) standard across many commercial and non-profit enterprises, government agencies, and educational institutions. Wireshark development thrives thanks to the volunteer contributions of networking experts around the globe and is the continuation of a project started by Gerald Combs in 1998.": "Wireshark es el analizador de protocolo de red más importante y ampliamente utilizado del mundo. Le permite ver lo que está sucediendo en su red a nivel microscópico y es el estándar de facto (y a menudo de jure) en muchas empresas comerciales y sin fines de lucro, agencias gubernamentales e instituciones educativas. El desarrollo de Wireshark prospera gracias a las contribuciones voluntarias de expertos en redes en todo el mundo y es la continuación de un proyecto iniciado por Gerald Combs en 1998.",
|
||||
"With another storage they stop running.": "Con otro almacenamiento dejan de ejecutarse.",
|
||||
"With dns validation, the DNSPLUGIN variable names the provider plugin. The advanced installation asks for it; otherwise add the line lxc.environment.runtime: DNSPLUGIN=<plugin> to /etc/pve/lxc/<CTID>.conf with the container stopped.": "Con la validación dns, la variable DNSPLUGIN indica el plugin del proveedor. La instalación avanzada la pide; si no, añade la línea lxc.environment.runtime: DNSPLUGIN=<plugin> a /etc/pve/lxc/<CTID>.conf con el contenedor detenido.",
|
||||
"With dns validation, write the provider credentials in /config/dns-conf/<plugin>.ini inside the container and restart it.": "Con la validación dns, escribe las credenciales del proveedor en /config/dns-conf/<plugin>.ini dentro del contenedor y reinícialo.",
|
||||
"With http validation, port 80 of the router must be forwarded to port 80 of this container.": "Con la validación http, el puerto 80 del router debe estar redirigido al puerto 80 de este contenedor.",
|
||||
@@ -7241,6 +7321,7 @@
|
||||
"ZFS tools not found. Install zfsutils-linux and retry.": "No se encontraron herramientas ZFS. Instale zfsutils-linux y vuelva a intentarlo.",
|
||||
"ZFS:": "ZFS:",
|
||||
"ZNC web interface": "Interfaz web de ZNC",
|
||||
"ZSTD - fast, good ratio (recommended)": "ZSTD - rápido, buena compresión (recomendado)",
|
||||
"Zen Browser is a free and open-source fork of Mozilla Firefox with a focus on privacy, customizability and design.": "Zen Browser es un fork gratuito y de código abierto de Mozilla Firefox centrado en la privacidad, la personalización y el diseño.",
|
||||
"Zero all data — partition table preserved, data wiped": "Poner a cero — conserva la tabla de particiones",
|
||||
"Zero all data — partition table preserved": "Cero todos los datos: se conserva la tabla de particiones",
|
||||
@@ -7257,6 +7338,7 @@
|
||||
"active Virtual Functions. Changing its driver binding would destroy every VF.": "Funciones virtuales activas. Cambiar la vinculación del controlador destruiría todos los VF.",
|
||||
"after copy; source tree is incomplete.": "después de la copia; El árbol fuente está incompleto.",
|
||||
"after update.": "después de la actualización.",
|
||||
"all guests": "todas las VM y CT",
|
||||
"all_squash,anonuid=65534,anongid=65534": "all_squash,anonuid=65534,anongid=65534",
|
||||
"allow TCP {port} from {subnet} via {bridge}": "permitir TCP {port} desde {subnet} por {bridge}",
|
||||
"amdgpu_top installed successfully": "amdgpu_top instalado correctamente",
|
||||
@@ -7322,6 +7404,7 @@
|
||||
"devices (dynamic runtime)": "dispositivos (tiempo de ejecución dinamico)",
|
||||
"did not become ready. Skipping.": "no estuvo listo. Salto a la comba.",
|
||||
"digiKam: Professional Photo Management with the Power of Open Source": "digiKam: gestión fotográfica profesional con la potencia del código abierto",
|
||||
"disabled": "desactivado",
|
||||
"disk(s) added to CT": "disco(s) agregado(s) a CT",
|
||||
"disk(s) added to VM": "discos agregados a la VM",
|
||||
"diskover is an open source file system indexer that uses Elasticsearch to index and manage data across heterogeneous storage systems.": "diskover es un indexador de sistemas de archivos de código abierto que usa Elasticsearch para indexar y gestionar datos en sistemas de almacenamiento heterogéneos.",
|
||||
@@ -7356,6 +7439,7 @@
|
||||
"files/folders": "archivos/carpetas",
|
||||
"filesystem. Skipping format.": "sistema de archivos. Saltar formato.",
|
||||
"filesystem...": "sistema de archivos...",
|
||||
"first of every month": "el día 1 de cada mes",
|
||||
"for better performance and stability": "para un mejor rendimiento y estabilidad",
|
||||
"for next boot (needs reboot to take effect)": "para el próximo arranque (es necesario reiniciar para que surta efecto)",
|
||||
"for this policy and may fail after first use or on subsequent VM starts.": "para esta política y puede fallar después del primer uso o en inicios posteriores de la máquina virtual.",
|
||||
@@ -7475,6 +7559,7 @@
|
||||
"journald drop-in created: /etc/systemd/journald.conf.d/proxmenux-loglevel.conf": "drop-in de diario creado: /etc/systemd/journald.conf.d/proxmenux-loglevel.conf",
|
||||
"journald log level restored": "Nivel de log de journald restaurado",
|
||||
"journald restarted - auth messages will now be stored": "Journald reiniciado: los mensajes de autenticación ahora se almacenarán",
|
||||
"jq is required for this menu and is not installed.": "Este menú necesita jq y no está instalado.",
|
||||
"jq is required for this operation but is not installed.": "Se requiere jq para esta operación pero no está instalado.",
|
||||
"jq is required to apply updates from this menu.": "Se requiere jq para aplicar actualizaciones desde este menú.",
|
||||
"jq not available — skipping rollback execution": "jq no disponible — se omite la ejecución del rollback",
|
||||
@@ -7538,6 +7623,7 @@
|
||||
"nouveau module unloaded successfully.": "El módulo nouveau se descargó correctamente.",
|
||||
"nouveau still active. Proceeding with installation. A reboot will be required for the driver to work.": "nouveau sigue activo. Continuando con la instalación. Será necesario reiniciar para que el controlador funcione.",
|
||||
"now": "ahora",
|
||||
"now, with its current settings?": "ahora, con sus ajustes actuales?",
|
||||
"now?": "¿ahora?",
|
||||
"nvidia-smi not found after install. Check log:": "nvidia-smi no se encuentra tras la instalación. Revise el log:",
|
||||
"nvidia-smi not found in PATH. Please verify the driver installation.": "nvidia-smi no encontrado en RUTA. Verifique la instalación del controlador.",
|
||||
@@ -7584,6 +7670,7 @@
|
||||
"pve-enterprise.list update skipped (no change)": "Actualización de pve-enterprise.list omitida (sin cambios)",
|
||||
"pve-enterprise.list updated to Trixie": "pve-enterprise.list actualizado a Trixie",
|
||||
"pvecm command not found (might not be in a cluster)": "No se encontró el comando pvecm (puede que no esté en un clúster)",
|
||||
"pvesh not found. This must run on a Proxmox VE node.": "No se encuentra pvesh. Esto debe ejecutarse en un nodo Proxmox VE.",
|
||||
"pvesm command found": "comando pvesm encontrado",
|
||||
"pvesm command not found. This should not happen on Proxmox.": "Comando pvesm no encontrado. Esto no debería suceder en Proxmox.",
|
||||
"pvesm not available.": "pvesm no disponible.",
|
||||
|
||||
@@ -4166,10 +4166,12 @@ main_menu() {
|
||||
4 "$(_bk_format_menu_item "$(translate "Scheduled backups and retention policies")" "")" \
|
||||
5 "$(_bk_format_menu_item "$(translate "Configure backup destinations (PBS, Borg, local)")" "")" \
|
||||
"" " " \
|
||||
6 "$(_bk_format_menu_item "$(translate "VM and CT backup jobs (Datacenter > Backup)")" "")" \
|
||||
"" " " \
|
||||
"" "\Z4───────────────────── $(translate "Community Scripts") ─────────────────────\Zn" \
|
||||
6 "$(_bk_format_menu_item "PVE Host Backup" "Helper-Scripts")" \
|
||||
7 "$(_bk_format_menu_item "proxmox_toolbox" "Tontonjo")" \
|
||||
8 "$(_bk_format_menu_item "proxsave" "tis24dev")" \
|
||||
7 "$(_bk_format_menu_item "PVE Host Backup" "Helper-Scripts")" \
|
||||
8 "$(_bk_format_menu_item "proxmox_toolbox" "Tontonjo")" \
|
||||
9 "$(_bk_format_menu_item "proxsave" "tis24dev")" \
|
||||
"" " " \
|
||||
0 "$(_bk_format_menu_item "$(translate "Return")" "")" \
|
||||
3>&1 1>&2 2>&3) || break
|
||||
@@ -4180,9 +4182,10 @@ main_menu() {
|
||||
3) _bk_manage_extra_paths ;;
|
||||
4) _bk_scheduler ;;
|
||||
5) _bk_manage_destinations ;;
|
||||
6) _bk_community_pve_host_backup ;;
|
||||
7) _bk_community_proxmox_toolbox ;;
|
||||
8) _bk_community_proxsave ;;
|
||||
6) bash "$LOCAL_SCRIPTS/backup_restore/vm_backup_jobs.sh" ;;
|
||||
7) _bk_community_pve_host_backup ;;
|
||||
8) _bk_community_proxmox_toolbox ;;
|
||||
9) _bk_community_proxsave ;;
|
||||
0) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - VM/CT Backup Jobs Manager
|
||||
# ==========================================================
|
||||
# Author : MattiaC46 (PR #421), MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : GPL-3.0
|
||||
# https://github.com/MacRimi/ProxMenux/blob/main/LICENSE
|
||||
# Version : 1.0
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# View, create, edit, enable/disable, delete and run on-demand
|
||||
# the PVE backup jobs normally found under
|
||||
# Datacenter > Backup
|
||||
# This is a thin wrapper around the official `pvesh` API
|
||||
# (/cluster/backup), so jobs created/edited here show up exactly
|
||||
# the same in the native PVE web UI and vice versa.
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
|
||||
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
elif [[ ! -f "$UTILS_FILE" ]]; then
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
else
|
||||
echo "Error: $UTILS_FILE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
BACKTITLE="ProxMenux"
|
||||
UI_W=84
|
||||
UI_H=22
|
||||
UI_LIST_H=12
|
||||
|
||||
function header() {
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "VM/CT Backup Jobs (Datacenter > Backup)")"
|
||||
}
|
||||
|
||||
# ── Sanity checks ──────────────────────────────────────────
|
||||
|
||||
if ! command -v pvesh >/dev/null 2>&1; then
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "pvesh not found. This must run on a Proxmox VE node.")" 8 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "jq is required for this menu and is not installed.")" 8 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
THIS_NODE=$(hostname)
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
# Lists storages that accept "backup" content, one per line: id<TAB>type
|
||||
list_backup_storages() {
|
||||
pvesh get /storage --output-format json 2>/dev/null \
|
||||
| jq -r '.[] | select(.content // "" | contains("backup")) | "\(.storage)\t\(.type)"'
|
||||
}
|
||||
|
||||
# Lists VMs and CTs on the whole cluster, one per line: vmid<TAB>name<TAB>kind
|
||||
list_guests() {
|
||||
{
|
||||
pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
|
||||
| jq -r '.[] | select(.type=="qemu" or .type=="lxc") | "\(.vmid)\t\(.name // "-")\t\(.type)"'
|
||||
} | sort -n
|
||||
}
|
||||
|
||||
# Host backups of ProxMenux that run with a Proxmox job, comma separated.
|
||||
attached_host_backups() {
|
||||
local job_id="$1" file found=()
|
||||
for file in /var/lib/proxmenux/backup-jobs/*.env; do
|
||||
[[ -f "$file" ]] || continue
|
||||
grep -qxF "PVE_PARENT_JOB=${job_id}" "$file" 2>/dev/null && found+=("$(basename "$file" .env)")
|
||||
done
|
||||
local IFS=,
|
||||
echo "${found[*]}"
|
||||
}
|
||||
|
||||
# Pretty one-line summary of a job, used in the main list
|
||||
job_summary_line() {
|
||||
local j="$1"
|
||||
local vmid storage schedule mode enabled all
|
||||
vmid=$(jq -r '.vmid // empty' <<<"$j")
|
||||
all=$(jq -r '.all // 0' <<<"$j")
|
||||
storage=$(jq -r '.storage // "-"' <<<"$j")
|
||||
schedule=$(jq -r '.schedule // "-"' <<<"$j")
|
||||
mode=$(jq -r '.mode // "snapshot"' <<<"$j")
|
||||
enabled=$(jq -r '.enabled // 1' <<<"$j")
|
||||
|
||||
local target
|
||||
if [[ "$all" == "1" || "$all" == "true" ]]; then
|
||||
target="$(translate "all guests")"
|
||||
elif [[ -n "$vmid" ]]; then
|
||||
target="VM/CT: $vmid"
|
||||
else
|
||||
target="$(translate "(no guests selected)")"
|
||||
fi
|
||||
|
||||
local state
|
||||
if [[ "$enabled" == "0" || "$enabled" == "false" ]]; then
|
||||
state="[$(translate "disabled")]"
|
||||
else
|
||||
state=""
|
||||
fi
|
||||
|
||||
echo "$schedule | $storage | $mode | $target $state"
|
||||
}
|
||||
|
||||
# ── Guest picker (checklist) ──────────────────────────────
|
||||
# Sets GUEST_SELECTION to "ALL" or a comma-separated vmid list.
|
||||
# Returns 1 on cancel.
|
||||
select_guests() {
|
||||
local pick
|
||||
pick=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Select Guests")" \
|
||||
--menu "\n$(translate "Which guests should this job back up?")" 12 70 2 \
|
||||
"ALL" "$(translate "All guests on the cluster")" \
|
||||
"PICK" "$(translate "Choose specific VMs/CTs")" \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
|
||||
if [[ "$pick" == "ALL" ]]; then
|
||||
GUEST_SELECTION="ALL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local guests=()
|
||||
while IFS=$'\t' read -r vmid name kind; do
|
||||
[[ -z "$vmid" ]] && continue
|
||||
guests+=("$vmid" "$name ($kind)" "off")
|
||||
done < <(list_guests)
|
||||
|
||||
if [[ ${#guests[@]} -eq 0 ]]; then
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "No VMs or CTs found on this cluster.")" 8 60
|
||||
return 1
|
||||
fi
|
||||
|
||||
local raw
|
||||
raw=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Select Guests")" \
|
||||
--checklist "\n$(translate "Mark the VMs/CTs to include in this job:")" 20 70 12 \
|
||||
"${guests[@]}" \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
|
||||
GUEST_SELECTION=$(echo "$raw" | tr -d '"' | tr -s ' ' ',' | sed 's/^,//;s/,$//')
|
||||
if [[ -z "$GUEST_SELECTION" ]]; then
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "No guest selected.")" 8 60
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Storage picker ─────────────────────────────────────────
|
||||
# Sets JOB_STORAGE. Returns 1 on cancel.
|
||||
select_storage() {
|
||||
local items=()
|
||||
while IFS=$'\t' read -r sid stype; do
|
||||
[[ -z "$sid" ]] && continue
|
||||
items+=("$sid" "$stype")
|
||||
done < <(list_backup_storages)
|
||||
|
||||
if [[ ${#items[@]} -eq 0 ]]; then
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "No storage with 'backup' content enabled was found.\n\nAdd one from Datacenter > Storage first (PBS, or a directory/NFS with the Backup content type).")" 10 70
|
||||
return 1
|
||||
fi
|
||||
|
||||
JOB_STORAGE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Target Storage")" \
|
||||
--menu "\n$(translate "Select the storage that will receive the backups:")" "$UI_H" "$UI_W" "$UI_LIST_H" \
|
||||
"${items[@]}" \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 || -z "$JOB_STORAGE" ]] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Schedule picker ─────────────────────────────────────────
|
||||
# Sets JOB_SCHEDULE (systemd calendar-event syntax). Returns 1 on cancel.
|
||||
select_schedule() {
|
||||
local pick
|
||||
pick=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Schedule")" \
|
||||
--menu "\n$(translate "Choose a schedule (systemd calendar-event syntax):")" 18 74 7 \
|
||||
"daily-02" "$(translate "Daily at 02:00")" \
|
||||
"daily-03" "$(translate "Daily at 03:00")" \
|
||||
"weekly-sun" "$(translate "Weekly, Sunday 02:00")" \
|
||||
"every-6h" "$(translate "Every 6 hours")" \
|
||||
"hourly" "$(translate "Every hour")" \
|
||||
"custom" "$(translate "Custom (type it myself)")" \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
|
||||
case "$pick" in
|
||||
daily-02) JOB_SCHEDULE="02:00" ;;
|
||||
daily-03) JOB_SCHEDULE="03:00" ;;
|
||||
weekly-sun) JOB_SCHEDULE="sun 02:00" ;;
|
||||
every-6h) JOB_SCHEDULE="00/6:00" ;;
|
||||
hourly) JOB_SCHEDULE="hourly" ;;
|
||||
custom)
|
||||
JOB_SCHEDULE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Custom Schedule")" \
|
||||
--inputbox "\n$(translate "Enter a systemd calendar event, e.g.:")\n 'mon..fri 01:30'\n '*-*-1 03:00' ($(translate "first of every month")))" \
|
||||
12 70 "02:00" 3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 || -z "$JOB_SCHEDULE" ]] && return 1
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Mode / compression / retention pickers ─────────────────
|
||||
|
||||
select_mode() {
|
||||
JOB_MODE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Backup Mode")" \
|
||||
--radiolist "\n$(translate "Select the backup mode:")" 14 74 3 \
|
||||
"snapshot" "$(translate "Snapshot - no downtime (recommended)")" on \
|
||||
"suspend" "$(translate "Suspend - pauses the guest during backup")" off \
|
||||
"stop" "$(translate "Stop - shuts the guest down during backup")" off \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 || -z "$JOB_MODE" ]] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
select_compress() {
|
||||
JOB_COMPRESS=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Compression")" \
|
||||
--radiolist "\n$(translate "Select compression:")" 14 74 4 \
|
||||
"zstd" "$(translate "ZSTD - fast, good ratio (recommended)")" on \
|
||||
"gzip" "$(translate "GZIP - slower, widely compatible")" off \
|
||||
"lzo" "$(translate "LZO - fastest, weaker ratio")" off \
|
||||
"0" "$(translate "None")" off \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 || -z "$JOB_COMPRESS" ]] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
input_retention() {
|
||||
local default="${1:-keep-last=3}"
|
||||
JOB_PRUNE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Retention")" \
|
||||
--inputbox "\n$(translate "Retention policy (prune-backups syntax).")\n$(translate "Examples:")\n keep-last=3\n keep-daily=7,keep-weekly=4,keep-monthly=6\n\n$(translate "Leave empty to keep every backup (not recommended).")" \
|
||||
14 74 "$default" 3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
input_notes() {
|
||||
local default="${1:-{{guestname}}}"
|
||||
JOB_NOTES=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Notes Template")" \
|
||||
--inputbox "\n$(translate "Notes template shown next to each backup (optional).")\n$(translate "Placeholders: {{guestname}} {{node}} {{vmid}}")" \
|
||||
12 74 "$default" 3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Create ───────────────────────────────────────────────
|
||||
|
||||
create_job_wizard() {
|
||||
header
|
||||
GUEST_SELECTION=""; JOB_STORAGE=""; JOB_SCHEDULE=""; JOB_MODE=""; JOB_COMPRESS=""; JOB_PRUNE=""; JOB_NOTES=""
|
||||
|
||||
select_guests || return 1
|
||||
select_storage || return 1
|
||||
select_schedule || return 1
|
||||
select_mode || return 1
|
||||
select_compress || return 1
|
||||
input_retention "keep-last=3" || return 1
|
||||
input_notes "{{guestname}}" || return 1
|
||||
|
||||
local job_id
|
||||
job_id=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Job ID (optional)")" \
|
||||
--inputbox "\n$(translate "Short name for this job (letters, numbers, hyphens).")\n$(translate "Leave empty to auto-generate one.")" \
|
||||
10 70 "" 3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
|
||||
local args=(create /cluster/backup --storage "$JOB_STORAGE" --schedule "$JOB_SCHEDULE" \
|
||||
--mode "$JOB_MODE" --compress "$JOB_COMPRESS" --enabled 1)
|
||||
[[ -n "$job_id" ]] && args+=(--id "$job_id")
|
||||
if [[ "$GUEST_SELECTION" == "ALL" ]]; then
|
||||
args+=(--all 1)
|
||||
else
|
||||
args+=(--vmid "$GUEST_SELECTION")
|
||||
fi
|
||||
[[ -n "$JOB_PRUNE" ]] && args+=(--prune-backups "$JOB_PRUNE")
|
||||
[[ -n "$JOB_NOTES" ]] && args+=(--notes-template "$JOB_NOTES")
|
||||
|
||||
header
|
||||
msg_info "$(translate "Creating backup job...")"
|
||||
local out
|
||||
if out=$(pvesh "${args[@]}" 2>&1); then
|
||||
msg_ok "$(translate "Backup job created successfully.")"
|
||||
else
|
||||
msg_error "$(translate "Failed to create the job.")"
|
||||
echo "$out"
|
||||
fi
|
||||
echo
|
||||
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
||||
}
|
||||
|
||||
# ── Edit ─────────────────────────────────────────────────
|
||||
|
||||
edit_job_wizard() {
|
||||
local job_id="$1"
|
||||
local current
|
||||
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
|
||||
if [[ -z "$current" ]]; then
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "Could not read this job.")" 8 60
|
||||
return 1
|
||||
fi
|
||||
|
||||
local cur_vmid cur_all cur_storage cur_schedule cur_mode cur_compress cur_prune cur_notes
|
||||
cur_vmid=$(jq -r '.vmid // empty' <<<"$current")
|
||||
cur_all=$(jq -r '.all // 0' <<<"$current")
|
||||
cur_storage=$(jq -r '.storage // empty' <<<"$current")
|
||||
cur_schedule=$(jq -r '.schedule // empty' <<<"$current")
|
||||
cur_mode=$(jq -r '.mode // "snapshot"' <<<"$current")
|
||||
cur_compress=$(jq -r '.compress // "zstd"' <<<"$current")
|
||||
cur_prune=$(jq -r '."prune-backups" // empty | if type == "object" then to_entries | map("\(.key)=\(.value)") | join(",") else . end' <<<"$current")
|
||||
cur_notes=$(jq -r '."notes-template" // empty' <<<"$current")
|
||||
|
||||
local field
|
||||
while true; do
|
||||
field=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Edit Job") $job_id" \
|
||||
--menu "\n$(translate "What do you want to change?")" 20 74 8 \
|
||||
"guests" "$(translate "Guests") ($( [[ "$cur_all" == "1" ]] && echo "all" || echo "$cur_vmid" ))" \
|
||||
"storage" "$(translate "Storage") ($cur_storage)" \
|
||||
"schedule" "$(translate "Schedule") ($cur_schedule)" \
|
||||
"mode" "$(translate "Mode") ($cur_mode)" \
|
||||
"compress" "$(translate "Compression") ($cur_compress)" \
|
||||
"prune" "$(translate "Retention") ($cur_prune)" \
|
||||
"notes" "$(translate "Notes template")" \
|
||||
"SAVE" "$(translate ">> Save and return <<")" \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
|
||||
case "$field" in
|
||||
guests)
|
||||
if select_guests; then
|
||||
if [[ "$GUEST_SELECTION" == "ALL" ]]; then
|
||||
cur_all=1; cur_vmid=""
|
||||
else
|
||||
cur_all=0; cur_vmid="$GUEST_SELECTION"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
storage)
|
||||
if select_storage; then
|
||||
local attached
|
||||
attached=$(attached_host_backups "$job_id")
|
||||
if [[ -n "$attached" && "$JOB_STORAGE" != "$cur_storage" ]]; then
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Storage")" \
|
||||
--msgbox "\n$(translate "These host backups run with this job and expect its storage:") $attached\n\n$(translate "With another storage they stop running.")" 12 70
|
||||
fi
|
||||
cur_storage="$JOB_STORAGE"
|
||||
fi
|
||||
;;
|
||||
schedule) select_schedule && cur_schedule="$JOB_SCHEDULE" ;;
|
||||
mode) select_mode && cur_mode="$JOB_MODE" ;;
|
||||
compress) select_compress && cur_compress="$JOB_COMPRESS" ;;
|
||||
prune) input_retention "$cur_prune" && cur_prune="$JOB_PRUNE" ;;
|
||||
notes) input_notes "$cur_notes" && cur_notes="$JOB_NOTES" ;;
|
||||
SAVE)
|
||||
local args=(set "/cluster/backup/$job_id" \
|
||||
--storage "$cur_storage" --schedule "$cur_schedule" \
|
||||
--mode "$cur_mode" --compress "$cur_compress")
|
||||
if [[ "$cur_all" == "1" ]]; then
|
||||
args+=(--all 1 --delete vmid)
|
||||
else
|
||||
args+=(--vmid "$cur_vmid" --delete all)
|
||||
fi
|
||||
if [[ -n "$cur_prune" ]]; then
|
||||
args+=(--prune-backups "$cur_prune")
|
||||
else
|
||||
args+=(--delete prune-backups)
|
||||
fi
|
||||
if [[ -n "$cur_notes" ]]; then
|
||||
args+=(--notes-template "$cur_notes")
|
||||
else
|
||||
args+=(--delete notes-template)
|
||||
fi
|
||||
|
||||
header
|
||||
msg_info "$(translate "Saving changes...")"
|
||||
local out
|
||||
if out=$(pvesh "${args[@]}" 2>&1); then
|
||||
msg_ok "$(translate "Job updated successfully.")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update the job.")"
|
||||
echo "$out"
|
||||
fi
|
||||
echo
|
||||
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ── Delete / Toggle / Run now ───────────────────────────────
|
||||
|
||||
delete_job() {
|
||||
local job_id="$1"
|
||||
local attached warning=""
|
||||
attached=$(attached_host_backups "$job_id")
|
||||
[[ -n "$attached" ]] && warning="\n\n$(translate "These host backups run with this job and will stop running:") $attached"
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Confirm Delete")" \
|
||||
--yesno "\n$(translate "Delete backup job") '$job_id'?\n\n$(translate "This only removes the schedule. Existing backup archives are NOT deleted.")${warning}" 14 70
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
|
||||
header
|
||||
msg_info "$(translate "Deleting job...")"
|
||||
local out
|
||||
if out=$(pvesh delete "/cluster/backup/$job_id" 2>&1); then
|
||||
msg_ok "$(translate "Job deleted.")"
|
||||
else
|
||||
msg_error "$(translate "Failed to delete the job.")"
|
||||
echo "$out"
|
||||
fi
|
||||
echo
|
||||
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
||||
}
|
||||
|
||||
toggle_job() {
|
||||
local job_id="$1" enabled="$2"
|
||||
local new=1
|
||||
[[ "$enabled" != "0" && "$enabled" != "false" ]] && new=0
|
||||
|
||||
header
|
||||
msg_info "$(translate "Updating job status...")"
|
||||
local out
|
||||
if out=$(pvesh set "/cluster/backup/$job_id" --enabled "$new" 2>&1); then
|
||||
if [[ "$new" == "1" ]]; then
|
||||
msg_ok "$(translate "Job enabled.")"
|
||||
else
|
||||
msg_ok "$(translate "Job disabled.")"
|
||||
fi
|
||||
else
|
||||
msg_error "$(translate "Failed to update the job.")"
|
||||
echo "$out"
|
||||
fi
|
||||
echo
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# Runs the job now with its own settings. Each node runs its part as a
|
||||
# Proxmox task, so it is listed and logged like a scheduled run.
|
||||
run_job_now() {
|
||||
local job_id="$1"
|
||||
local current
|
||||
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
|
||||
[[ -z "$current" ]] && return 1
|
||||
|
||||
local mode
|
||||
mode=$(jq -r '.mode // "snapshot"' <<<"$current")
|
||||
local warning=""
|
||||
[[ "$mode" == "stop" ]] && warning="\n\n$(translate "The mode of this job is stop: its guests are shut down during the backup.")"
|
||||
|
||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Run Now")" \
|
||||
--yesno "\n$(translate "Run job") '$job_id' $(translate "now, with its current settings?")\n\n$(translate "The backup runs as a Proxmox task; follow it in the task log of Proxmox.")${warning}" 14 74
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
|
||||
header
|
||||
msg_info "$(translate "Starting the backup...")"
|
||||
|
||||
local nodes=() node
|
||||
node=$(jq -r '.node // empty' <<<"$current")
|
||||
if [[ -n "$node" ]]; then
|
||||
nodes=("$node")
|
||||
elif [[ "$(jq -r '.all // 0' <<<"$current")" =~ ^(1|true)$ || -n "$(jq -r '.pool // empty' <<<"$current")" ]]; then
|
||||
mapfile -t nodes < <(pvesh get /nodes --output-format json 2>/dev/null \
|
||||
| jq -r '.[] | select(.status=="online") | .node')
|
||||
else
|
||||
local wanted
|
||||
wanted=$(jq -c '(.vmid // "") | tostring | split(",") | map(gsub(" "; ""))' <<<"$current")
|
||||
mapfile -t nodes < <(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
|
||||
| jq -r --argjson wanted "$wanted" '[.[] | select((.vmid|tostring) as $id | $wanted | index($id)) | .node] | unique | .[]')
|
||||
fi
|
||||
|
||||
if [[ ${#nodes[@]} -eq 0 ]]; then
|
||||
msg_error "$(translate "No online node has a guest of this job.")"
|
||||
echo
|
||||
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Every option of the job that vzdump understands, as it is stored.
|
||||
local run_args=()
|
||||
mapfile -t run_args < <(jq -r '
|
||||
del(.id, .type, .schedule, .enabled, ."next-run", .comment, ."repeat-missed", .digest, .node, .starttime, .dow)
|
||||
| to_entries[] | select(.value != null and .value != "")
|
||||
| "--\(.key)",
|
||||
(if (.value|type) == "object" then (.value | to_entries | map("\(.key)=\(.value)") | join(","))
|
||||
elif (.value|type) == "boolean" then (if .value then "1" else "0" end)
|
||||
else (.value|tostring) end)' <<<"$current")
|
||||
|
||||
stop_spinner
|
||||
local out
|
||||
for node in "${nodes[@]}"; do
|
||||
if out=$(pvesh create "/nodes/$node/vzdump" "${run_args[@]}" 2>&1); then
|
||||
msg_ok "$(translate "Backup started on node") $node"
|
||||
echo -e "${TAB}$(grep -o 'UPID:[^"[:space:]]*' <<<"$out" | head -1)"
|
||||
else
|
||||
msg_error "$(translate "The backup could not be started on node") $node"
|
||||
echo "$out"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
|
||||
}
|
||||
|
||||
# ── Job list / detail menu ──────────────────────────────────
|
||||
|
||||
job_detail_menu() {
|
||||
local job_id="$1"
|
||||
while true; do
|
||||
local current enabled
|
||||
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
|
||||
[[ -z "$current" ]] && return
|
||||
enabled=$(jq -r '.enabled // 1' <<<"$current")
|
||||
|
||||
local toggle_label
|
||||
if [[ "$enabled" == "0" || "$enabled" == "false" ]]; then
|
||||
toggle_label="$(translate "Enable job")"
|
||||
else
|
||||
toggle_label="$(translate "Disable job")"
|
||||
fi
|
||||
|
||||
local choice
|
||||
choice=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Job") $job_id" \
|
||||
--menu "\n$(job_summary_line "$current")" 16 78 6 \
|
||||
"run" "$(translate "Run now")" \
|
||||
"edit" "$(translate "Edit")" \
|
||||
"toggle" "$toggle_label" \
|
||||
"delete" "$(translate "Delete")" \
|
||||
"back" "$(translate "Back to job list")" \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 || "$choice" == "back" ]] && return
|
||||
|
||||
case "$choice" in
|
||||
run) run_job_now "$job_id" ;;
|
||||
edit) edit_job_wizard "$job_id" ;;
|
||||
toggle) toggle_job "$job_id" "$enabled" ;;
|
||||
delete) delete_job "$job_id"; return ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
list_jobs_menu() {
|
||||
while true; do
|
||||
local jobs_json
|
||||
jobs_json=$(pvesh get /cluster/backup --output-format json 2>/dev/null)
|
||||
|
||||
local count
|
||||
count=$(jq 'length' <<<"${jobs_json:-[]}" 2>/dev/null)
|
||||
[[ -z "$count" ]] && count=0
|
||||
|
||||
local items=()
|
||||
if [[ "$count" -gt 0 ]]; then
|
||||
while IFS=$'\t' read -r jid jsummary; do
|
||||
[[ -z "$jid" ]] && continue
|
||||
items+=("$jid" "$jsummary")
|
||||
done < <(jq -r '.[] | [.id, (.schedule // "-")+" | "+(.storage // "-")+" | "+(.vmid // (if .all==1 then "all" else "-" end))] | @tsv' <<<"$jobs_json")
|
||||
fi
|
||||
|
||||
items+=("NEW" "$(translate ">> Create a new backup job <<")")
|
||||
items+=("BACK" "$(translate "Return")")
|
||||
|
||||
header
|
||||
local choice
|
||||
choice=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Backup Jobs") ($THIS_NODE)" \
|
||||
--menu "\n$(translate "Select a job to view/edit, or create a new one:")" 22 86 12 \
|
||||
"${items[@]}" \
|
||||
3>&1 1>&2 2>&3)
|
||||
[[ $? -ne 0 || "$choice" == "BACK" ]] && exit 0
|
||||
|
||||
if [[ "$choice" == "NEW" ]]; then
|
||||
create_job_wizard
|
||||
else
|
||||
job_detail_menu "$choice"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ── Entry point ──────────────────────────────────────────────
|
||||
list_jobs_menu
|
||||
@@ -0,0 +1,174 @@
|
||||
"""VM/CT backup jobs API: what reaches pvesh, with pvesh itself replaced."""
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from flask import Flask
|
||||
except ImportError:
|
||||
Flask = None
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
|
||||
|
||||
JOB = {"id": "backup-nightly", "all": 0, "vmid": "100,101", "storage": "local", "schedule": "02:00",
|
||||
"mode": "snapshot", "compress": "zstd", "enabled": 1, "prune-backups": {"keep-last": 3, "keep-weekly": 2},
|
||||
"notes-template": "{{guestname}}", "next-run": 1790000000, "type": "vzdump"}
|
||||
NODES = [{"node": "pve1", "status": "online"}, {"node": "pve2", "status": "online"}, {"node": "pve3", "status": "offline"}]
|
||||
GUESTS = [{"vmid": 100, "name": "web", "type": "qemu", "node": "pve1"},
|
||||
{"vmid": 101, "name": "db", "type": "lxc", "node": "pve1"},
|
||||
{"vmid": 200, "name": "other", "type": "qemu", "node": "pve2"}]
|
||||
|
||||
|
||||
@unittest.skipIf(Flask is None, "Flask runtime required")
|
||||
class VmBackupJobsApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
middleware = types.ModuleType("jwt_middleware")
|
||||
self.guards = []
|
||||
|
||||
def guard(name):
|
||||
def decorator(function):
|
||||
self.guards.append((name, function.__name__))
|
||||
return function
|
||||
return decorator
|
||||
middleware.require_auth = guard("auth")
|
||||
middleware.require_admin_scope = guard("admin")
|
||||
with patch.dict(sys.modules, jwt_middleware=middleware):
|
||||
sys.modules.pop("flask_vm_backup_routes", None)
|
||||
self.routes = importlib.import_module("flask_vm_backup_routes")
|
||||
self.addCleanup(lambda: sys.modules.pop("flask_vm_backup_routes", None))
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(self.routes.vm_backup_bp)
|
||||
self.client = app.test_client()
|
||||
self.calls = []
|
||||
self.job = json.loads(json.dumps(JOB))
|
||||
|
||||
def pvesh(self, failing=()):
|
||||
def run(args, timeout=25):
|
||||
self.calls.append(list(args))
|
||||
if args[:2] == ["get", "/cluster/backup"]:
|
||||
return True, json.dumps([self.job])
|
||||
if args[0] == "get" and args[1].startswith("/cluster/backup/"):
|
||||
return True, json.dumps(self.job)
|
||||
if args[:2] == ["get", "/nodes"]:
|
||||
return True, json.dumps(NODES)
|
||||
if args[:2] == ["get", "/cluster/resources"]:
|
||||
return True, json.dumps(GUESTS)
|
||||
if args[:2] == ["get", "/storage"]:
|
||||
return True, json.dumps([{"storage": "local", "type": "dir", "content": "iso,backup"},
|
||||
{"storage": "isos", "type": "dir", "content": "iso,vztmpl"}])
|
||||
if args[0] == "create" and args[1].endswith("/vzdump"):
|
||||
node = args[1].split("/")[2]
|
||||
if node in failing:
|
||||
return False, "storage is not online"
|
||||
return True, f'"UPID:{node}:0001:0002:0003:vzdump::root@pam:"\n'
|
||||
return True, ""
|
||||
return patch.object(self.routes, "_pvesh", side_effect=run)
|
||||
|
||||
def changes(self):
|
||||
return [call for call in self.calls if call[0] in ("create", "set", "delete")]
|
||||
|
||||
def test_reading_needs_a_session_and_changing_needs_an_administrator(self):
|
||||
guards = dict((name, kind) for kind, name in self.guards)
|
||||
self.assertEqual({name for name, kind in guards.items() if kind == "auth"}, {"list_jobs", "options", "get_job"})
|
||||
self.assertEqual({name for name, kind in guards.items() if kind == "admin"},
|
||||
{"create_job", "update_job", "delete_job", "toggle_job", "run_job"})
|
||||
|
||||
def test_the_list_gives_text_properties_and_the_host_backups_that_hang_from_a_job(self):
|
||||
with self.pvesh(), patch.object(self.routes, "_host_backups_attached",
|
||||
return_value={"backup-nightly": ["hostcfg-attached-local"]}):
|
||||
answer = self.client.get("/api/vm-backup-jobs").get_json()
|
||||
job = answer["jobs"][0]
|
||||
self.assertEqual(job["prune-backups"], "keep-last=3,keep-weekly=2")
|
||||
self.assertEqual(job["host_backups"], ["hostcfg-attached-local"])
|
||||
|
||||
def test_only_storages_that_take_backups_are_offered(self):
|
||||
with self.pvesh():
|
||||
answer = self.client.get("/api/vm-backup-jobs/options").get_json()
|
||||
self.assertEqual([storage["id"] for storage in answer["storages"]], ["local"])
|
||||
self.assertEqual([guest["vmid"] for guest in answer["guests"]], [100, 101, 200])
|
||||
|
||||
def test_a_new_job_reaches_proxmox_with_its_fields(self):
|
||||
with self.pvesh():
|
||||
answer = self.client.post("/api/vm-backup-jobs", json={
|
||||
"id": "nightly", "all": 0, "vmid": "100, 101", "storage": "local", "schedule": "mon..fri 01:30",
|
||||
"mode": "stop", "compress": "zstd", "prune_backups": "keep-last=3", "notes_template": "{{guestname}}"})
|
||||
self.assertEqual(answer.status_code, 200)
|
||||
self.assertEqual(self.changes(), [[
|
||||
"create", "/cluster/backup", "--vmid", "100,101", "--all", "0", "--storage", "local",
|
||||
"--schedule", "mon..fri 01:30", "--mode", "stop", "--compress", "zstd", "--prune-backups", "keep-last=3",
|
||||
"--notes-template", "{{guestname}}", "--id", "nightly", "--enabled", "1"]])
|
||||
|
||||
def test_what_is_not_a_valid_value_never_reaches_proxmox(self):
|
||||
good = {"all": 1, "storage": "local", "schedule": "02:00"}
|
||||
bad = [{"storage": "local; rm -rf /"}, {"storage": "--delete"}, {"schedule": "--all 1"}, {"schedule": "02:00\n--storage x"},
|
||||
{"mode": "fast"}, {"compress": "xz"}, {"prune_backups": "keep-last=3 --all 1"}, {"prune_backups": "forever"},
|
||||
{"notes_template": "-x"}, {"all": 0, "vmid": "100;200"}, {"all": 0, "vmid": ""}, {"id": "../etc"}, {"id": "-x"}]
|
||||
with self.pvesh():
|
||||
for change in bad:
|
||||
answer = self.client.post("/api/vm-backup-jobs", json={**good, **change})
|
||||
self.assertEqual(answer.status_code, 400, change)
|
||||
for name in ("..", "a b", "x;y", "-x"):
|
||||
self.assertIn(self.client.delete(f"/api/vm-backup-jobs/{name}").status_code, (400, 404), name)
|
||||
self.assertEqual(self.changes(), [])
|
||||
|
||||
def test_a_change_only_sends_what_changed_and_clears_what_was_emptied(self):
|
||||
with self.pvesh():
|
||||
answer = self.client.put("/api/vm-backup-jobs/backup-nightly", json={
|
||||
"all": 1, "storage": "pbs", "prune_backups": "", "notes_template": ""})
|
||||
self.assertEqual(answer.status_code, 200)
|
||||
self.assertEqual(self.changes(), [[
|
||||
"set", "/cluster/backup/backup-nightly", "--all", "1", "--storage", "pbs",
|
||||
"--delete", "vmid", "--delete", "prune-backups", "--delete", "notes-template"]])
|
||||
|
||||
def test_toggling_flips_what_the_job_has_now(self):
|
||||
with self.pvesh():
|
||||
self.assertEqual(self.client.post("/api/vm-backup-jobs/backup-nightly/toggle").get_json()["enabled"], False)
|
||||
self.job["enabled"] = 0
|
||||
self.assertEqual(self.client.post("/api/vm-backup-jobs/backup-nightly/toggle").get_json()["enabled"], True)
|
||||
self.assertEqual([call[-1] for call in self.changes()], ["0", "1"])
|
||||
|
||||
def test_running_starts_a_task_on_each_node_that_hosts_a_guest_of_the_job(self):
|
||||
with self.pvesh():
|
||||
answer = self.client.post("/api/vm-backup-jobs/backup-nightly/run").get_json()
|
||||
self.assertEqual([task["node"] for task in answer["tasks"]], ["pve1"])
|
||||
self.assertTrue(answer["tasks"][0]["upid"].startswith("UPID:pve1:"))
|
||||
started = self.changes()
|
||||
self.assertEqual(len(started), 1)
|
||||
self.assertEqual(started[0][:2], ["create", "/nodes/pve1/vzdump"])
|
||||
options = dict(zip(started[0][2::2], started[0][3::2]))
|
||||
self.assertEqual(options, {"--all": "0", "--compress": "zstd", "--mode": "snapshot", "--notes-template": "{{guestname}}",
|
||||
"--prune-backups": "keep-last=3,keep-weekly=2", "--storage": "local", "--vmid": "100,101"})
|
||||
|
||||
def test_a_job_of_every_guest_runs_on_every_online_node_and_reports_the_ones_that_fail(self):
|
||||
self.job.update(all=1, vmid=None)
|
||||
with self.pvesh(failing=("pve2",)):
|
||||
answer = self.client.post("/api/vm-backup-jobs/backup-nightly/run").get_json()
|
||||
self.assertEqual([task["node"] for task in answer["tasks"]], ["pve1"])
|
||||
self.assertEqual([failure["node"] for failure in answer["failures"]], ["pve2"])
|
||||
self.assertNotIn("/nodes/pve3/vzdump", [call[1] for call in self.calls])
|
||||
|
||||
def test_a_job_whose_guests_are_on_no_online_node_starts_nothing(self):
|
||||
self.job["vmid"] = "999"
|
||||
with self.pvesh():
|
||||
answer = self.client.post("/api/vm-backup-jobs/backup-nightly/run")
|
||||
self.assertEqual(answer.status_code, 400)
|
||||
self.assertEqual(self.changes(), [])
|
||||
|
||||
def test_host_backups_are_matched_by_the_job_they_name(self):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
Path(folder, "hostcfg-a.env").write_text("JOB_ID=hostcfg-a\nPVE_STORAGE=local\nPVE_PARENT_JOB=backup-nightly\n")
|
||||
Path(folder, "hostcfg-b.env").write_text("JOB_ID=hostcfg-b\nON_CALENDAR=daily\n")
|
||||
Path(folder, "notes.txt").write_text("PVE_PARENT_JOB=backup-nightly\n")
|
||||
with patch.object(self.routes, "HOST_BACKUP_JOBS", folder):
|
||||
self.assertEqual(self.routes._host_backups_attached(), {"backup-nightly": ["hostcfg-a"]})
|
||||
with patch.object(self.routes, "HOST_BACKUP_JOBS", "/nonexistent"):
|
||||
self.assertEqual(self.routes._host_backups_attached(), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user