mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-09 06:56:37 +00:00
feat(backup): manage the VM and CT backup jobs of Proxmox
Co-authored-by: MattiaC46 <52442052+MattiaC46@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
// The Backup tab: the backups of the host and the backup jobs of the VMs
|
||||
// and LXCs, each in its own view.
|
||||
|
||||
import { useState } from "react"
|
||||
import { DatabaseBackup } from "lucide-react"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type BackupView = "host" | "guests"
|
||||
|
||||
export function BackupTab() {
|
||||
const t = useT()
|
||||
const [view, setView] = useState<BackupView>("host")
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseBackup className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("navigation.backup")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("backup.viewSwitch.ariaLabel")}
|
||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||
sm:inline-flex sm:w-auto"
|
||||
>
|
||||
{(["host", "guests"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={view === key}
|
||||
onClick={() => setView(key)}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
|
||||
view === key
|
||||
? "bg-blue-500 text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`backup.viewSwitch.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* The host view stays mounted: it follows a running backup or restore. */}
|
||||
<div className={view === "host" ? "" : "hidden"}>
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
@@ -289,7 +290,7 @@ export function CustomLinkEditor({
|
||||
</Button>
|
||||
) : <div />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("apps.customLinkCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -49,7 +49,7 @@ import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { RestoreProgressCard } from "./restore-progress-card"
|
||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||
import { formatStorage, formatBytes } from "../lib/utils"
|
||||
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
||||
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
||||
import { useI18n, useT } from "../lib/i18n/provider"
|
||||
|
||||
@@ -1513,7 +1513,7 @@ export function HostBackup() {
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline" className={cancelButtonClass}
|
||||
onClick={() => setJobToDelete(null)}
|
||||
disabled={busyJobId === jobToDelete?.id}
|
||||
>
|
||||
@@ -2308,7 +2308,7 @@ function InspectModal({
|
||||
</DialogHeader>
|
||||
{kf && <KeyfileErrorBlock err={kf} />}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -2414,7 +2414,7 @@ function InspectModal({
|
||||
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={deleteArchive} disabled={deletingArchive}>
|
||||
@@ -2445,7 +2445,7 @@ function InspectModal({
|
||||
{archiveLog?.content ?? ""}
|
||||
</pre>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowArchiveFullLog(false)}>{t("actions.close")}</Button>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowArchiveFullLog(false)}>{t("actions.close")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -4316,7 +4316,7 @@ function CreateJobDialog({
|
||||
{/* Footer navigation */}
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline" className={cancelButtonClass}
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
@@ -5213,7 +5213,7 @@ function ManualBackupDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={submitting}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -5728,7 +5728,7 @@ function DestinationsSection({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setConfirmingDest(null)}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setConfirmingDest(null)}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDeleteDest}>
|
||||
@@ -6052,7 +6052,7 @@ function ConfigureDestinationWizard({
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={onClose}>{t("actions.cancel")}</Button>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose}>{t("actions.cancel")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -6613,7 +6613,7 @@ function AddDestinationDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-border">
|
||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!canSubmit || submitting}
|
||||
@@ -6862,7 +6862,7 @@ function UsbPicker({
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline" className={cancelButtonClass}
|
||||
onClick={() => { setFormatTarget(null); setFormatTyped("") }}
|
||||
disabled={busyKey === formatTarget?.path_or_device}
|
||||
>
|
||||
@@ -7285,7 +7285,7 @@ function UsbDrivesSection() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -7781,7 +7781,7 @@ function JobDetailModal({
|
||||
{detail?.id}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowDisableConfirm(false)}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowDisableConfirm(false)}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -8164,7 +8164,7 @@ function PbsKeyfileRecoveryDialog({
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={busy}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -9039,7 +9039,7 @@ function RestoreOptionsModal({
|
||||
{t("backup.actions.back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={onClose}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
{step === "custom" && (
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Settings } from "./settings"
|
||||
import { Security } from "./security"
|
||||
import { Profile } from "./profile"
|
||||
import { About } from "./about"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { BackupTab } from "./backup-tab"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -849,7 +849,7 @@ export function ProxmoxDashboard() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||
<HostBackup key={`backup-${componentKey}`} />
|
||||
<BackupTab key={`backup-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
"use client"
|
||||
|
||||
// The backup jobs of Proxmox for VMs and CTs (Datacenter > Backup).
|
||||
// Based on the contribution of MattiaC46 (PR #421).
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { ScrollArea } from "./ui/scroll-area"
|
||||
import {
|
||||
AlertTriangle, Archive, Calendar, CalendarClock, CheckCircle2, ChevronRight, DatabaseBackup, FileText, HardDrive,
|
||||
Layers, Loader2, Pencil, PlayCircle, Plus, Power, Server, Trash2,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface VmBackupJob {
|
||||
id: string
|
||||
enabled?: number | boolean | string
|
||||
schedule?: string
|
||||
storage?: string
|
||||
mode?: string
|
||||
compress?: string | number
|
||||
all?: number | boolean | string
|
||||
vmid?: string
|
||||
node?: string
|
||||
"prune-backups"?: string
|
||||
"notes-template"?: string
|
||||
host_backups?: string[]
|
||||
}
|
||||
|
||||
interface Guest { vmid: number; name: string; type: string; node?: string }
|
||||
interface Options { storages: { id: string; type: string }[]; guests: Guest[] }
|
||||
interface RunResult { tasks: { node: string; upid: string }[]; failures: { node: string; error: string }[] }
|
||||
|
||||
interface FormState {
|
||||
id?: string
|
||||
name: string
|
||||
allGuests: boolean
|
||||
vmids: string[]
|
||||
storage: string
|
||||
originalStorage: string
|
||||
schedule: string
|
||||
mode: string
|
||||
compress: string
|
||||
retention: Record<string, string>
|
||||
keepAll: boolean
|
||||
notes: string
|
||||
attached: string[]
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ key: "daily02", value: "02:00" },
|
||||
{ key: "daily03", value: "03:00" },
|
||||
{ key: "sunday02", value: "sun 02:00" },
|
||||
{ key: "every6h", value: "00/6:00" },
|
||||
{ key: "hourly", value: "hourly" },
|
||||
]
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: "", allGuests: true, vmids: [], storage: "", originalStorage: "", schedule: "02:00",
|
||||
mode: "snapshot", compress: "zstd", retention: {}, keepAll: false, notes: "{{guestname}}", attached: [],
|
||||
}
|
||||
|
||||
// The limits a job can keep, in the order Proxmox lists them.
|
||||
const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
const parseRetention = (prune: string) => {
|
||||
const fields: Record<string, string> = {}
|
||||
let keepAll = false
|
||||
for (const part of prune.split(",")) {
|
||||
const [key, value] = part.trim().split("=")
|
||||
if (key === "keep-all") keepAll = value === "1"
|
||||
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
|
||||
}
|
||||
return { fields, keepAll }
|
||||
}
|
||||
|
||||
const buildRetention = (fields: Record<string, string>, keepAll: boolean) => {
|
||||
const parts = RETENTION.filter((key) => Number(fields[key]) > 0).map((key) => `keep-${key}=${Number(fields[key])}`)
|
||||
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
|
||||
}
|
||||
|
||||
const isOn = (value: unknown) => value === 1 || value === true || value === "1"
|
||||
const jobEnabled = (job: VmBackupJob) => job.enabled === undefined || isOn(job.enabled)
|
||||
const guestIds = (job: VmBackupJob) => String(job.vmid ?? "").split(",").map((id) => id.trim()).filter(Boolean)
|
||||
|
||||
const fetcher = async (url: string) => fetchApi<any>(url)
|
||||
|
||||
async function call(path: string, method: string, body?: unknown) {
|
||||
return fetchApi<any>(path, { method, body: body ? JSON.stringify(body) : undefined })
|
||||
}
|
||||
|
||||
export function VmBackupJobs() {
|
||||
const t = useT()
|
||||
const { data, error, mutate } = useSWR<{ jobs: VmBackupJob[] }>("/api/vm-backup-jobs", fetcher, { refreshInterval: 30000 })
|
||||
const { data: options } = useSWR<Options>("/api/vm-backup-jobs/options", fetcher, { refreshInterval: 60000 })
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState<VmBackupJob | null>(null)
|
||||
const [running, setRunning] = useState<VmBackupJob | null>(null)
|
||||
const [disabling, setDisabling] = useState<VmBackupJob | null>(null)
|
||||
const [viewingId, setViewingId] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
const jobs = data?.jobs ?? []
|
||||
const viewing = jobs.find((job) => job.id === viewingId) ?? null
|
||||
const guests = options?.guests ?? []
|
||||
const storages = options?.storages ?? []
|
||||
|
||||
const describeGuests = (job: VmBackupJob) => {
|
||||
if (isOn(job.all)) return t("backup.vmJobs.allGuests")
|
||||
const ids = guestIds(job)
|
||||
if (ids.length === 0) return t("backup.vmJobs.noGuests")
|
||||
return ids.map((id) => {
|
||||
const guest = guests.find((item) => String(item.vmid) === id)
|
||||
return guest ? `${id} (${guest.name})` : id
|
||||
}).join(", ")
|
||||
}
|
||||
|
||||
const openEdit = (job: VmBackupJob) => {
|
||||
setFormError(null)
|
||||
setForm({
|
||||
id: job.id,
|
||||
name: job.id,
|
||||
allGuests: isOn(job.all),
|
||||
vmids: guestIds(job),
|
||||
storage: job.storage ?? "",
|
||||
originalStorage: job.storage ?? "",
|
||||
schedule: job.schedule ?? "02:00",
|
||||
mode: job.mode ?? "snapshot",
|
||||
compress: String(job.compress ?? "zstd"),
|
||||
retention: parseRetention(job["prune-backups"] ?? "").fields,
|
||||
keepAll: parseRetention(job["prune-backups"] ?? "").keepAll,
|
||||
notes: job["notes-template"] ?? "",
|
||||
attached: job.host_backups ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
if (!form.storage) return setFormError(t("backup.vmJobs.needStorage"))
|
||||
if (!form.allGuests && form.vmids.length === 0) return setFormError(t("backup.vmJobs.needGuests"))
|
||||
setBusy("save")
|
||||
setFormError(null)
|
||||
const payload: Record<string, unknown> = {
|
||||
all: form.allGuests ? 1 : 0,
|
||||
vmid: form.allGuests ? "" : form.vmids.join(","),
|
||||
storage: form.storage,
|
||||
schedule: form.schedule.trim(),
|
||||
mode: form.mode,
|
||||
compress: form.compress,
|
||||
prune_backups: buildRetention(form.retention, form.keepAll),
|
||||
notes_template: form.notes.trim(),
|
||||
}
|
||||
if (!form.id && form.name.trim()) payload.id = form.name.trim()
|
||||
try {
|
||||
if (form.id) await call(`/api/vm-backup-jobs/${encodeURIComponent(form.id)}`, "PUT", payload)
|
||||
else await call("/api/vm-backup-jobs", "POST", payload)
|
||||
setMessage({ ok: true, text: form.id ? t("backup.vmJobs.updated") : t("backup.vmJobs.created") })
|
||||
setForm(null)
|
||||
mutate()
|
||||
} catch (problem) {
|
||||
setFormError(problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.saveFailed"))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (job: VmBackupJob) => {
|
||||
setBusy(job.id)
|
||||
try {
|
||||
const result = await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}/toggle`, "POST")
|
||||
setMessage({ ok: true, text: t(result?.enabled ? "backup.vmJobs.toggledOn" : "backup.vmJobs.toggledOff", { id: job.id }) })
|
||||
mutate()
|
||||
} catch (problem) {
|
||||
setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
setDisabling(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
if (!running) return
|
||||
const job = running
|
||||
setBusy(job.id)
|
||||
try {
|
||||
const result: RunResult = await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}/run`, "POST")
|
||||
let text = t("backup.vmJobs.started", { id: job.id, nodes: (result.tasks ?? []).map((task) => task.node).join(", ") })
|
||||
if (result.failures?.length) {
|
||||
text += " " + t("backup.vmJobs.startedPartial", { nodes: result.failures.map((failure) => failure.node).join(", ") })
|
||||
}
|
||||
setMessage({ ok: !result.failures?.length, text })
|
||||
} catch (problem) {
|
||||
setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
setRunning(null)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting) return
|
||||
const job = deleting
|
||||
setBusy(job.id)
|
||||
try {
|
||||
await call(`/api/vm-backup-jobs/${encodeURIComponent(job.id)}`, "DELETE")
|
||||
setMessage({ ok: true, text: t("backup.vmJobs.deleted", { id: job.id }) })
|
||||
setViewingId(null)
|
||||
mutate()
|
||||
} catch (problem) {
|
||||
setMessage({ ok: false, text: problem instanceof Error && problem.message ? problem.message : t("backup.vmJobs.actionFailed") })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleGuest = (vmid: string) =>
|
||||
setForm((current) => current && ({
|
||||
...current,
|
||||
vmids: current.vmids.includes(vmid) ? current.vmids.filter((id) => id !== vmid) : [...current.vmids, vmid],
|
||||
}))
|
||||
|
||||
const preset = form && PRESETS.some((item) => item.value === form.schedule) ? form.schedule : "custom"
|
||||
const retentionOf = (job: VmBackupJob) => (job["prune-backups"] ?? "").split(",").map((part) => part.trim()).filter(Boolean)
|
||||
const compressionOf = (job: VmBackupJob) =>
|
||||
String(job.compress ?? "zstd") === "0" ? t("backup.vmJobs.compressNone") : String(job.compress ?? "zstd")
|
||||
|
||||
const retentionChips = (job: VmBackupJob) => {
|
||||
const parts = retentionOf(job)
|
||||
if (parts.length === 0) return <span className="text-muted-foreground">{t("backup.vmJobs.storageDefault")}</span>
|
||||
return parts.map((part) => {
|
||||
const [label, value] = part.split("=")
|
||||
const name = label.replace(/^keep-/, "")
|
||||
return (
|
||||
<span key={part} className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded border border-border bg-background/60">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{(RETENTION as readonly string[]).includes(name) ? t(`backup.retention.${name}`) : name}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-foreground">{value}</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const badges = (job: VmBackupJob) => (
|
||||
<>
|
||||
{!jobEnabled(job) && (
|
||||
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
{(job.host_backups ?? []).length > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5"
|
||||
title={t("backup.vmJobs.attachedHost", { ids: (job.host_backups ?? []).join(", ") })}
|
||||
>
|
||||
{t("backup.vmJobs.hostBackup")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CalendarClock className="h-5 w-5 text-blue-500 shrink-0" />
|
||||
<CardTitle className="text-base font-semibold truncate">{t("backup.vmJobs.title")}</CardTitle>
|
||||
<Badge variant="outline" className="ml-1">{jobs.length}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-3 bg-blue-500 hover:bg-blue-600 text-white shrink-0"
|
||||
onClick={() => { setFormError(null); setForm({ ...EMPTY_FORM, storage: storages[0]?.id ?? "" }) }}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("backup.vmJobs.newJob")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("backup.vmJobs.subtitle")}</p>
|
||||
|
||||
{message && !viewing && (
|
||||
<div
|
||||
role="status"
|
||||
className={`text-xs px-3 py-2 rounded-md border whitespace-pre-wrap break-words ${
|
||||
message.ok ? "text-green-500 border-green-500/30 bg-green-500/10" : "text-red-500 border-red-500/30 bg-red-500/10"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="text-sm text-red-500 py-4">{t("backup.vmJobs.loadFailed")}</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("backup.common.loading")}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4">{t("backup.vmJobs.empty")}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((job) => (
|
||||
<button
|
||||
key={job.id}
|
||||
type="button"
|
||||
onClick={() => { setMessage(null); setViewingId(job.id) }}
|
||||
className="w-full text-left flex items-start gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 transition-colors group"
|
||||
title={t("backup.jobs.openJobTitle")}
|
||||
>
|
||||
<div className="min-w-0 flex-1 w-full">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-base truncate" title={job.id}>{job.id}</span>
|
||||
{badges(job)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground line-clamp-2 break-words">{describeGuests(job)}</div>
|
||||
<div className="mt-2 text-xs text-foreground flex items-center gap-x-4 gap-y-1 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1" title={t("backup.vmJobs.labelSchedule")}>
|
||||
<Calendar className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="font-mono">{job.schedule || "—"}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1" title={t("backup.vmJobs.labelStorage")}>
|
||||
<HardDrive className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>{job.storage || "—"}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1" title={t("backup.vmJobs.labelMode")}>
|
||||
<Layers className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>{job.mode ?? "snapshot"} · {compressionOf(job)}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 flex-wrap" title={t("backup.vmJobs.labelRetention")}>
|
||||
<Archive className="h-3.5 w-3.5 text-green-500" />
|
||||
{retentionChips(job)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-blue-400 transition-colors shrink-0 mt-1" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Job detail */}
|
||||
<Dialog open={!!viewing} onOpenChange={(open) => { if (!open) { setViewingId(null); setMessage(null) } }}>
|
||||
<DialogContent className="max-w-3xl bg-card border-border overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 flex-wrap text-base pr-8">
|
||||
<DatabaseBackup className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-mono break-all">{viewing?.id}</span>
|
||||
{viewing && badges(viewing)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.subtitle")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
role="status"
|
||||
className={`text-xs px-3 py-2 rounded-md border whitespace-pre-wrap break-words ${
|
||||
message.ok ? "text-green-500 border-green-500/30 bg-green-500/10" : "text-red-500 border-red-500/30 bg-red-500/10"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<ScrollArea className="max-h-[60vh] pr-2">
|
||||
<div className="space-y-4 text-sm">
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-emerald-400">
|
||||
<Server className="h-3.5 w-3.5" /> {t("backup.vmJobs.fieldGuests")}
|
||||
</h4>
|
||||
<div className="break-words">{describeGuests(viewing)}</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-amber-400">
|
||||
<Calendar className="h-3.5 w-3.5" /> {t("backup.vmJobs.labelSchedule")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.labelSchedule")}</div>
|
||||
<div className="font-mono break-all">{viewing.schedule || "—"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.labelRetention")}</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">{retentionChips(viewing)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-blue-400">
|
||||
<HardDrive className="h-3.5 w-3.5" /> {t("backup.vmJobs.labelStorage")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.labelStorage")}</div>
|
||||
<div className="break-words">{viewing.storage || "—"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.labelMode")}</div>
|
||||
<div>{viewing.mode ?? "snapshot"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.vmJobs.fieldCompress")}</div>
|
||||
<div>{compressionOf(viewing)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{viewing["notes-template"] && (
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" /> {t("backup.vmJobs.fieldNotes")}
|
||||
</h4>
|
||||
<div className="font-mono break-all">{viewing["notes-template"]}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(viewing.host_backups ?? []).length > 0 && (
|
||||
<div className="rounded-md border border-blue-500/40 bg-blue-500/5 p-2 text-xs">
|
||||
{t("backup.vmJobs.attachedHost", { ids: (viewing.host_backups ?? []).join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<div className="border-t border-border pt-3 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy !== null}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={() => setRunning(viewing)}
|
||||
>
|
||||
{busy === viewing.id && running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{t("backup.actions.runNow")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
className="bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
|
||||
onClick={() => { const job = viewing; setViewingId(null); setMessage(null); openEdit(job) }}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1" />
|
||||
{t("backup.actions.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
className={jobEnabled(viewing)
|
||||
? "bg-amber-500/10 border-amber-500/40 !text-amber-400 hover:bg-amber-500/20 hover:!text-amber-300"
|
||||
: "bg-emerald-500/10 border-emerald-500/40 !text-emerald-400 hover:bg-emerald-500/20 hover:!text-emerald-300"}
|
||||
onClick={() => (jobEnabled(viewing) ? setDisabling(viewing) : toggle(viewing))}
|
||||
>
|
||||
{busy === viewing.id && !running && !deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : jobEnabled(viewing) ? (
|
||||
<Power className="h-3.5 w-3.5 mr-1" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{jobEnabled(viewing) ? t("backup.actions.disable") : t("backup.actions.enable")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
className="bg-red-500/10 border-red-500/40 !text-red-400 hover:bg-red-500/20 hover:!text-red-300"
|
||||
onClick={() => setDeleting(viewing)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
{t("backup.actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create / edit: edit mode, the editable fields sunken */}
|
||||
<Dialog open={!!form} onOpenChange={(open) => !open && setForm(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarClock className="h-5 w-5 text-blue-500" />
|
||||
{form?.id ? t("backup.vmJobs.dialogEdit", { id: form.id }) : t("backup.vmJobs.dialogNew")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.subtitle")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{form && (
|
||||
<div className="flex-1 overflow-y-auto pr-1 space-y-4 text-sm">
|
||||
{!form.id && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vm-job-name">{t("backup.vmJobs.fieldName")}</Label>
|
||||
<Input id="vm-job-name" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
<p className="text-xs text-muted-foreground">{t("backup.vmJobs.fieldNameHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("backup.vmJobs.fieldGuests")}</Label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={form.allGuests} onCheckedChange={(checked) => setForm({ ...form, allGuests: checked === true })} />
|
||||
{t("backup.vmJobs.fieldAll")}
|
||||
</label>
|
||||
{!form.allGuests && (
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border border-border bg-background p-2 grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||
{guests.map((guest) => (
|
||||
<label key={guest.vmid} className="flex items-center gap-2 min-w-0">
|
||||
<Checkbox checked={form.vmids.includes(String(guest.vmid))} onCheckedChange={() => toggleGuest(String(guest.vmid))} />
|
||||
<span className="truncate">{guest.vmid} · {guest.name}</span>
|
||||
<span className="text-muted-foreground shrink-0">({guest.type === "qemu" ? "VM" : "LXC"})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>{t("backup.vmJobs.fieldStorage")}</Label>
|
||||
{storages.length === 0 ? (
|
||||
<p className="text-xs text-amber-500">{t("backup.vmJobs.noStorage")}</p>
|
||||
) : (
|
||||
<Select value={form.storage} onValueChange={(value) => setForm({ ...form, storage: value })}>
|
||||
<SelectTrigger><SelectValue placeholder={t("backup.vmJobs.selectStorage")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{storages.map((storage) => (
|
||||
<SelectItem key={storage.id} value={storage.id}>{storage.id} ({storage.type})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{form.attached.length > 0 && form.storage !== form.originalStorage && (
|
||||
<p className="text-xs text-amber-500 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{t("backup.vmJobs.storageAttached", { ids: form.attached.join(", ") })}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("backup.vmJobs.fieldSchedule")}</Label>
|
||||
<Select value={preset} onValueChange={(value) => value !== "custom" && setForm({ ...form, schedule: value })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESETS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>{t(`backup.vmJobs.presets.${item.key}`)}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">{t("backup.vmJobs.scheduleCustom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vm-job-schedule">{t("backup.vmJobs.scheduleCustom")}</Label>
|
||||
<Input
|
||||
id="vm-job-schedule"
|
||||
className="font-mono"
|
||||
value={form.schedule}
|
||||
onChange={(event) => setForm({ ...form, schedule: event.target.value })}
|
||||
placeholder={t("backup.vmJobs.schedulePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("backup.vmJobs.fieldMode")}</Label>
|
||||
<Select value={form.mode} onValueChange={(value) => setForm({ ...form, mode: value })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="snapshot">{t("backup.vmJobs.modeSnapshot")}</SelectItem>
|
||||
<SelectItem value="suspend">{t("backup.vmJobs.modeSuspend")}</SelectItem>
|
||||
<SelectItem value="stop">{t("backup.vmJobs.modeStop")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("backup.vmJobs.fieldCompress")}</Label>
|
||||
<Select value={form.compress} onValueChange={(value) => setForm({ ...form, compress: value })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zstd">zstd</SelectItem>
|
||||
<SelectItem value="gzip">gzip</SelectItem>
|
||||
<SelectItem value="lzo">lzo</SelectItem>
|
||||
<SelectItem value="0">{t("backup.vmJobs.compressNone")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t("backup.retention.title")}</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t("backup.retention.zeroDisables")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{RETENTION.map((key) => (
|
||||
<div key={key}>
|
||||
<Label htmlFor={`vm-job-keep-${key}`} className="text-xs">
|
||||
{t(`backup.retention.keep${key[0].toUpperCase()}${key.slice(1)}`)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`vm-job-keep-${key}`}
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.retention[key] ?? ""}
|
||||
onChange={(event) => setForm({ ...form, retention: { ...form.retention, [key]: event.target.value } })}
|
||||
className="font-mono mt-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vm-job-notes">{t("backup.vmJobs.fieldNotes")}</Label>
|
||||
<Input id="vm-job-notes" value={form.notes} onChange={(event) => setForm({ ...form, notes: event.target.value })} />
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cancelButtonClass}
|
||||
onClick={() => setForm(null)}
|
||||
disabled={busy === "save"}
|
||||
>
|
||||
{t("backup.vmJobs.cancel")}
|
||||
</Button>
|
||||
<Button className="bg-blue-500 hover:bg-blue-600 text-white" disabled={busy === "save"} onClick={save}>
|
||||
{busy === "save" && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
{busy === "save" ? t("backup.vmJobs.saving") : t("backup.vmJobs.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Run now */}
|
||||
<Dialog open={!!running} onOpenChange={(open) => !open && setRunning(null)}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5 text-green-500" />
|
||||
{t("backup.vmJobs.runTitle", { id: running?.id ?? "" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.runBody")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{running?.mode === "stop" && (
|
||||
<p className="text-xs text-amber-500 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{t("backup.vmJobs.runStop")}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setRunning(null)}>{t("backup.vmJobs.cancel")}</Button>
|
||||
<Button className="bg-green-600 hover:bg-green-700 text-white" disabled={busy !== null} onClick={runNow}>
|
||||
{t("backup.actions.runNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Disable */}
|
||||
<Dialog open={!!disabling} onOpenChange={(open) => !open && setDisabling(null)}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
<Power className="h-5 w-5 text-amber-500" />
|
||||
{t("backup.vmJobs.disableTitle", { id: disabling?.id ?? "" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.disableBody")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setDisabling(null)}>{t("backup.vmJobs.cancel")}</Button>
|
||||
<Button
|
||||
className="bg-amber-500/10 border border-amber-500/40 !text-amber-400 hover:bg-amber-500/20 hover:!text-amber-300"
|
||||
disabled={busy !== null}
|
||||
onClick={() => disabling && toggle(disabling)}
|
||||
>
|
||||
{t("backup.actions.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete */}
|
||||
<Dialog open={!!deleting} onOpenChange={(open) => !open && setDeleting(null)}>
|
||||
<DialogContent className="max-w-md bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
{t("backup.vmJobs.deleteTitle", { id: deleting?.id ?? "" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("backup.vmJobs.deleteBody")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(deleting?.host_backups?.length ?? 0) > 0 && (
|
||||
<p className="text-xs text-amber-500 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{t("backup.vmJobs.deleteAttached", { ids: (deleting?.host_backups ?? []).join(", ") })}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className={cancelButtonClass} onClick={() => setDeleting(null)}>{t("backup.vmJobs.cancel")}</Button>
|
||||
<Button className="bg-red-600 hover:bg-red-700 text-white" disabled={busy !== null} onClick={confirmDelete}>
|
||||
{t("backup.vmJobs.deleteConfirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user