mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-13 03:47:00 +00:00
update beta 1.2.2.2
This commit is contained in:
600
AppImage/components/host-backup.tsx
Normal file
600
AppImage/components/host-backup.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Button } from "./ui/button"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import {
|
||||
DatabaseBackup,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Server,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
Archive,
|
||||
FileSearch,
|
||||
Calendar,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
|
||||
// ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ──
|
||||
|
||||
interface BackupJob {
|
||||
id: string
|
||||
destination: string
|
||||
method: string // "local_tar" | "pbs" | "borg" | "unknown"
|
||||
on_calendar: string
|
||||
retention: string
|
||||
timer_enabled: boolean
|
||||
last_status: string | null
|
||||
next_run: string | null
|
||||
}
|
||||
|
||||
interface BackupArchive {
|
||||
id: string // basename of the .tar file (also the URL slug)
|
||||
path: string // absolute path on host
|
||||
size_bytes: number
|
||||
mtime: number // unix seconds
|
||||
// From the backend identifier — see _identify_host_backup() in flask_server.py.
|
||||
// kind: "manual" / "scheduled" when we know; "legacy" when only the in-tar
|
||||
// marker confirmed it's a ProxMenux backup (no sidecar, no name match).
|
||||
job_id: string | null
|
||||
kind: "manual" | "scheduled" | "legacy"
|
||||
profile: string | null
|
||||
source_hostname: string | null
|
||||
// Which detection path identified this archive. Surfaced as a small tooltip
|
||||
// hint so the operator knows whether the metadata is authoritative
|
||||
// (sidecar) or inferred (filename / tar-peek).
|
||||
detected_via: "sidecar" | "job_id_match" | "hostcfg_prefix" | "tar_peek"
|
||||
}
|
||||
|
||||
interface ManifestSourceHost {
|
||||
hostname: string
|
||||
pve_version: string | null
|
||||
roles: string[]
|
||||
kernel: string
|
||||
boot_mode: string
|
||||
cpu_model: string
|
||||
memory_kb: number
|
||||
}
|
||||
|
||||
interface PreflightCheck {
|
||||
id: string
|
||||
severity: "pass" | "warn" | "fail"
|
||||
message: string
|
||||
details: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface PreflightReport {
|
||||
source_host_at_backup: ManifestSourceHost
|
||||
selected_mode: {
|
||||
mode: string
|
||||
paths_include: string[]
|
||||
paths_exclude: string[]
|
||||
components_include: string[]
|
||||
storage_apply: boolean
|
||||
network_apply: boolean
|
||||
}
|
||||
preflight: {
|
||||
checks: PreflightCheck[]
|
||||
summary: { pass: number; warn: number; fail: number }
|
||||
}
|
||||
storage: {
|
||||
zfs: Array<{ name: string; action: string; present: string[]; missing: string[] }>
|
||||
lvm: Array<{ name: string; action: string }>
|
||||
pve_storage: Array<{ id: string; type: string; action: string; note: string | null }>
|
||||
in_selected_mode: boolean
|
||||
}
|
||||
network: {
|
||||
keep: Array<{ ifname: string; mac: string }>
|
||||
remap: Array<{ source_ifname: string; destination_ifname: string; mac: string }>
|
||||
orphan: Array<{ source_ifname: string; source_mac: string }>
|
||||
new: Array<{ ifname: string; mac: string }>
|
||||
in_selected_mode: boolean
|
||||
}
|
||||
driver_reinstall: {
|
||||
plan: Array<{
|
||||
component_id: string
|
||||
type: string
|
||||
version: string
|
||||
installer: string | null
|
||||
action: string
|
||||
reason: string
|
||||
}>
|
||||
}
|
||||
abort_reason: string | null
|
||||
}
|
||||
|
||||
const fetcher = async (url: string) => fetchApi(url)
|
||||
|
||||
const formatMtime = (mtime: number) =>
|
||||
new Date(mtime * 1000).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
|
||||
const formatNext = (iso: string | null) => {
|
||||
if (!iso) return "—"
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export function HostBackup() {
|
||||
const { data: jobsResp, error: jobsErr } = useSWR<{ jobs: BackupJob[] }>(
|
||||
"/api/host-backups/jobs",
|
||||
fetcher,
|
||||
{ refreshInterval: 30000 },
|
||||
)
|
||||
const { data: archivesResp, error: archivesErr } = useSWR<{ archives: BackupArchive[] }>(
|
||||
"/api/host-backups/archives",
|
||||
fetcher,
|
||||
{ refreshInterval: 30000 },
|
||||
)
|
||||
|
||||
const [inspectingArchive, setInspectingArchive] = useState<BackupArchive | null>(null)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* ── Scheduled jobs ───────────────────────────────── */}
|
||||
<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">
|
||||
<Calendar className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle className="text-base font-semibold">Scheduled Backup Jobs</CardTitle>
|
||||
</div>
|
||||
<Badge variant="outline">{jobsResp?.jobs?.length ?? 0}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{jobsErr ? (
|
||||
<div className="text-sm text-red-500 py-4">Failed to load jobs</div>
|
||||
) : !jobsResp ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : jobsResp.jobs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4 space-y-2">
|
||||
<p>No scheduled backup jobs configured yet.</p>
|
||||
<p>
|
||||
For a <span className="font-medium text-foreground">one-shot manual backup</span>{" "}
|
||||
or to create a scheduled job, run:
|
||||
</p>
|
||||
<code className="block mt-1 px-3 py-2 rounded-md bg-muted text-xs font-mono">
|
||||
bash /usr/local/share/proxmenux/scripts/backup_restore/backup_host.sh
|
||||
</code>
|
||||
<p className="text-xs">
|
||||
Menu options 1-6 are manual backups (default or custom paths, to PBS, Borg, or local tar). Option 7 opens the scheduler if you want a recurring job.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground border-b border-border">
|
||||
<tr>
|
||||
<th className="text-left px-2 py-2">ID</th>
|
||||
<th className="text-left px-2 py-2">Destination</th>
|
||||
<th className="text-left px-2 py-2">Method</th>
|
||||
<th className="text-left px-2 py-2">Schedule</th>
|
||||
<th className="text-left px-2 py-2">Last status</th>
|
||||
<th className="text-left px-2 py-2">Next run</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{jobsResp.jobs.map((j) => (
|
||||
<tr key={j.id} className="text-xs">
|
||||
<td className="px-2 py-2 font-mono">{j.id}</td>
|
||||
<td className="px-2 py-2 font-mono truncate max-w-[260px]" title={j.destination}>
|
||||
{j.destination || "—"}
|
||||
</td>
|
||||
<td className="px-2 py-2">{j.method}</td>
|
||||
<td className="px-2 py-2 font-mono">{j.on_calendar}</td>
|
||||
<td className="px-2 py-2">
|
||||
{j.last_status ? (
|
||||
<span className="text-xs">{j.last_status}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">never</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className="text-xs">{formatNext(j.next_run)}</span>
|
||||
{!j.timer_enabled && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-500 border-amber-500/30">
|
||||
timer disabled
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Available archives ─────────────────────────────── */}
|
||||
<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">
|
||||
<Archive className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle className="text-base font-semibold">Available Archives</CardTitle>
|
||||
</div>
|
||||
<Badge variant="outline">{archivesResp?.archives?.length ?? 0}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{archivesErr ? (
|
||||
<div className="text-sm text-red-500 py-4">Failed to load archives</div>
|
||||
) : !archivesResp ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : archivesResp.archives.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4">
|
||||
No backup archives found on this host. We scan <code className="font-mono">/var/lib/vz/dump</code> and any custom destination from a scheduled job, looking for files named <code className="font-mono">hostcfg-<hostname>-*.tar.zst</code> (manual backups) or <code className="font-mono"><job_id>-*.tar.*</code> (scheduled). PBS and Borg backups aren't surfaced in the UI yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{archivesResp.archives.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-background/40 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-xs truncate" title={a.path}>
|
||||
{a.id}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-3 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatMtime(a.mtime)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{formatStorage(a.size_bytes)}
|
||||
</span>
|
||||
{a.kind === "scheduled" && a.job_id ? (
|
||||
<span title={`identified via ${a.detected_via}`}>job: <code className="font-mono">{a.job_id}</code></span>
|
||||
) : a.kind === "legacy" ? (
|
||||
<span
|
||||
title={`identified via ${a.detected_via} — no sidecar metadata`}
|
||||
className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
||||
>
|
||||
legacy
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
title={`identified via ${a.detected_via}`}
|
||||
className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-white/5 border border-border"
|
||||
>
|
||||
manual
|
||||
</span>
|
||||
)}
|
||||
{a.source_hostname && a.source_hostname !== "" && (
|
||||
<span>host: <code className="font-mono">{a.source_hostname}</code></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setInspectingArchive(a)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<FileSearch className="h-3.5 w-3.5 mr-1.5" />
|
||||
Inspect
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Inspect / preflight modal ──────────────────────── */}
|
||||
<InspectModal
|
||||
archive={inspectingArchive}
|
||||
onClose={() => setInspectingArchive(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Inspect modal — shows manifest summary + lets the operator pick
|
||||
// a restore mode and run the dry-run preflight + plan against this
|
||||
// host. No mutating actions; --apply stays on the CLI for 1.3.0.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function InspectModal({
|
||||
archive,
|
||||
onClose,
|
||||
}: {
|
||||
archive: BackupArchive | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const open = archive !== null
|
||||
const [mode, setMode] = useState<string>("full")
|
||||
const [report, setReport] = useState<PreflightReport | null>(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { data: manifest, error: manifestErr } = useSWR<{
|
||||
source_host: ManifestSourceHost
|
||||
proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }>
|
||||
vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] }
|
||||
storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } }
|
||||
}>(
|
||||
archive ? `/api/host-backups/archives/${encodeURIComponent(archive.id)}/manifest` : null,
|
||||
fetcher,
|
||||
)
|
||||
|
||||
const runPreflight = async () => {
|
||||
if (!archive) return
|
||||
setRunning(true)
|
||||
setError(null)
|
||||
setReport(null)
|
||||
try {
|
||||
const res = await fetchApi<PreflightReport>(
|
||||
`/api/host-backups/archives/${encodeURIComponent(archive.id)}/preflight`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode }),
|
||||
},
|
||||
)
|
||||
setReport(res)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Preflight failed")
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state when archive changes
|
||||
const archiveId = archive?.id
|
||||
// Note: this useEffect-like cleanup happens via key={archiveId} on the
|
||||
// Dialog content so React unmounts and remounts; state resets naturally.
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent key={archiveId} className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<DatabaseBackup className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-mono text-sm truncate">{archive?.id}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Inspect the manifest snapshot taken at backup time, then dry-run the restore plan for a chosen mode. Read-only; nothing on this host is changed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Manifest summary */}
|
||||
{manifestErr ? (
|
||||
<div className="text-sm text-red-500 py-2">
|
||||
Couldn't read the manifest from this archive — it may have been created before the manifest format was added.
|
||||
</div>
|
||||
) : !manifest ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Reading manifest...
|
||||
</div>
|
||||
) : (
|
||||
<ManifestSummary manifest={manifest} />
|
||||
)}
|
||||
|
||||
{/* Preflight controls */}
|
||||
<div className="border-t border-border pt-4 space-y-3">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground block mb-1.5">
|
||||
Restore mode
|
||||
</label>
|
||||
<Select value={mode} onValueChange={setMode}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">Full — apply everything</SelectItem>
|
||||
<SelectItem value="base">Base — everything except network</SelectItem>
|
||||
<SelectItem value="storage_only">Storage only</SelectItem>
|
||||
<SelectItem value="network_only">Network only</SelectItem>
|
||||
<SelectItem value="custom">Custom — paths picked manually</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={runPreflight} disabled={running || !manifest}>
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircle className="h-4 w-4 mr-2" />
|
||||
Run preflight
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 p-2 rounded-md border border-red-500/30 bg-red-500/10">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && <PreflightReportView report={report} />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Manifest summary panel ───────────────────────────────────
|
||||
function ManifestSummary({
|
||||
manifest,
|
||||
}: {
|
||||
manifest: {
|
||||
source_host: ManifestSourceHost
|
||||
proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }>
|
||||
vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] }
|
||||
storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } }
|
||||
}
|
||||
}) {
|
||||
const sh = manifest.source_host
|
||||
const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0
|
||||
const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0
|
||||
return (
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
|
||||
<Field icon={<Server className="h-3.5 w-3.5" />} label="Source host" value={sh.hostname} />
|
||||
<Field label="PVE version" value={sh.pve_version || "—"} />
|
||||
<Field label="Roles" value={sh.roles.join(", ")} />
|
||||
<Field label="Kernel" value={sh.kernel} mono />
|
||||
<Field label="Boot mode" value={sh.boot_mode} />
|
||||
<Field label="Memory" value={`${Math.round(sh.memory_kb / 1024)} MB`} />
|
||||
<Field label="ZFS pools" value={String(zfsCount)} />
|
||||
<Field label="LVM VGs" value={String(lvmCount)} />
|
||||
<Field label="VMs / LXCs" value={`${manifest.vms_lxcs_at_backup.vms.length} VM / ${manifest.vms_lxcs_at_backup.lxcs.length} LXC`} />
|
||||
</div>
|
||||
{manifest.proxmenux_installed_components.length > 0 && (
|
||||
<div className="text-xs">
|
||||
<div className="text-muted-foreground mb-1">ProxMenux components at backup time:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{manifest.proxmenux_installed_components.map((c) => (
|
||||
<Badge key={c.id} variant="outline" className="font-mono text-[10px]">
|
||||
{c.id}{c.version_at_backup ? ` @ ${c.version_at_backup}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ icon, label, value, mono }: { icon?: React.ReactNode; label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={`${mono ? "font-mono" : ""} truncate`} title={value}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Preflight report view ────────────────────────────────────
|
||||
function PreflightReportView({ report }: { report: PreflightReport }) {
|
||||
const { summary, checks } = report.preflight
|
||||
const passColor = "text-emerald-500"
|
||||
const warnColor = "text-amber-500"
|
||||
const failColor = "text-red-500"
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border border-border rounded-md p-3 bg-muted/30">
|
||||
{/* Summary line */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className={`inline-flex items-center gap-1 ${passColor}`}>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{summary.pass} pass
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1 ${warnColor}`}>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{summary.warn} warn
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1 ${failColor}`}>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{summary.fail} fail
|
||||
</span>
|
||||
{summary.fail > 0 && (
|
||||
<span className="ml-auto text-xs text-red-500">
|
||||
--apply would be refused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-check list */}
|
||||
<div className="space-y-1.5">
|
||||
{checks.map((c) => {
|
||||
const color =
|
||||
c.severity === "pass" ? passColor :
|
||||
c.severity === "warn" ? warnColor :
|
||||
failColor
|
||||
const Icon =
|
||||
c.severity === "pass" ? CheckCircle2 :
|
||||
c.severity === "warn" ? AlertTriangle :
|
||||
XCircle
|
||||
return (
|
||||
<div key={c.id} className="flex items-start gap-2 text-xs">
|
||||
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`font-mono ${color}`}>{c.id}</span>
|
||||
<span className="text-muted-foreground ml-2">{c.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Storage / network counts */}
|
||||
<div className="grid grid-cols-2 gap-3 text-xs pt-2 border-t border-border/40">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">Storage [in mode: {String(report.storage.in_selected_mode)}]</div>
|
||||
<div>
|
||||
{report.storage.zfs.length} ZFS pool(s) ·
|
||||
{" "}{report.storage.lvm.length} LVM VG(s) ·
|
||||
{" "}{report.storage.pve_storage.length} PVE storage(s)
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">Network [in mode: {String(report.network.in_selected_mode)}]</div>
|
||||
<div>
|
||||
{report.network.keep.length} keep ·
|
||||
{" "}{report.network.remap.length} remap ·
|
||||
{" "}{report.network.orphan.length} orphan ·
|
||||
{" "}{report.network.new.length} new
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver plan */}
|
||||
{report.driver_reinstall.plan.length > 0 && (
|
||||
<div className="text-xs pt-2 border-t border-border/40">
|
||||
<div className="text-muted-foreground mb-1.5">Driver reinstall plan ({report.driver_reinstall.plan.length})</div>
|
||||
<div className="space-y-1">
|
||||
{report.driver_reinstall.plan.map((p) => (
|
||||
<div key={p.component_id} className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{p.component_id}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{p.action}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Abort reason (if --apply would have been refused) */}
|
||||
{report.abort_reason && (
|
||||
<div className="text-xs text-red-500 p-2 border border-red-500/30 rounded-md bg-red-500/10">
|
||||
{report.abort_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Settings } from "./settings"
|
||||
import { Security } from "./security"
|
||||
import { Profile } from "./profile"
|
||||
import { About } from "./about"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -30,17 +31,26 @@ import {
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
NetworkIcon,
|
||||
Box,
|
||||
Boxes,
|
||||
Cpu,
|
||||
FileText,
|
||||
ScrollText,
|
||||
SettingsIcon,
|
||||
Settings2,
|
||||
Terminal,
|
||||
ShieldCheck,
|
||||
Info,
|
||||
DatabaseBackup,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
|
||||
interface SystemStatus {
|
||||
status: "healthy" | "warning" | "critical"
|
||||
@@ -352,28 +362,19 @@ export function ProxmoxDashboard() {
|
||||
|
||||
const getActiveTabLabel = () => {
|
||||
switch (activeTab) {
|
||||
case "overview":
|
||||
return "Overview"
|
||||
case "storage":
|
||||
return "Storage"
|
||||
case "network":
|
||||
return "Network"
|
||||
case "vms":
|
||||
return "VMs & LXCs"
|
||||
case "hardware":
|
||||
return "Hardware"
|
||||
case "terminal":
|
||||
return "Terminal"
|
||||
case "logs":
|
||||
return "System Logs"
|
||||
case "security":
|
||||
return "Security"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
case "profile":
|
||||
return "Profile"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
case "overview": return "Overview"
|
||||
case "vms": return "VMs & LXCs"
|
||||
case "storage": return "Storage"
|
||||
case "network": return "Network"
|
||||
case "hardware": return "Hardware"
|
||||
case "backup": return "Backup"
|
||||
case "terminal": return "Terminal"
|
||||
case "logs": return "System Logs"
|
||||
case "security": return "Security"
|
||||
case "settings": return "Settings"
|
||||
case "about": return "About"
|
||||
case "profile": return "Profile"
|
||||
default: return "Navigation Menu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,71 +566,128 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
<div className="container mx-auto px-4 lg:px-6 pt-4 lg:pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||
{/* Issue #191: 10 tabs after adding About. The grid wraps via
|
||||
Tabs primitives so the extra column doesn't push the
|
||||
triggers off-screen on common laptop widths. */}
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-10 bg-card border border-border">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="storage"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="network"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Network
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="vms"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
VMs & LXCs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="hardware"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Hardware
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="logs"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
System Logs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Security
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="about"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
About
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Sprint 13D nav redesign — 6 top-level slots in usage order:
|
||||
Overview · VMs & LXCs · Node ⌄ · Backup · Terminal · Admin ⌄
|
||||
Node groups Storage / Network / Hardware (3 sub-items).
|
||||
Admin groups System Logs / Security / Settings / About
|
||||
(will split when RBAC arrives in 1.5.0).
|
||||
Backup is direct now (only Host Backup); becomes a dropdown
|
||||
when VM/LXC centralised backup ships. */}
|
||||
{(() => {
|
||||
const triggerActiveClass =
|
||||
"data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
// Each dropdown lists its children in the order they
|
||||
// render. When one of them is the active tab, the dropdown
|
||||
// trigger swaps its label + icon to that child — same
|
||||
// pattern macOS Settings uses inside a category: the
|
||||
// crumb shows where you are, the chevron tells you the
|
||||
// siblings are one click away.
|
||||
const NODE_ITEMS = [
|
||||
{ value: "storage", label: "Storage", Icon: HardDrive, default: false },
|
||||
{ value: "network", label: "Network", Icon: NetworkIcon, default: false },
|
||||
{ value: "hardware", label: "Hardware", Icon: Cpu, default: false },
|
||||
]
|
||||
const ADMIN_ITEMS = [
|
||||
{ value: "logs", label: "System Logs", Icon: ScrollText, default: false },
|
||||
{ value: "security", label: "Security", Icon: ShieldCheck, default: false },
|
||||
{ value: "settings", label: "Settings", Icon: SettingsIcon, default: false },
|
||||
{ value: "about", label: "About", Icon: Info, default: false },
|
||||
]
|
||||
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
||||
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
||||
const isNodeActive = activeNodeItem !== undefined
|
||||
const isAdminActive = activeAdminItem !== undefined
|
||||
// The trigger label + icon shown on the bar. When a child
|
||||
// is active we surface IT; otherwise the group default.
|
||||
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
|
||||
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : "Node"
|
||||
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
|
||||
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : "Admin"
|
||||
// Dropdown trigger styling: parity with TabsTrigger so the
|
||||
// parent visibly carries the "I'm the selected section"
|
||||
// signal when any of its children is the active tab —
|
||||
// same blue background + white text + rounded as a direct
|
||||
// tab. Without this the user lands on Storage and the
|
||||
// entire top bar looks idle.
|
||||
const dropdownBtnClass = (active: boolean) =>
|
||||
`inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
|
||||
active
|
||||
? "bg-blue-500 text-white rounded-md"
|
||||
: "text-muted-foreground hover:text-foreground rounded-sm"
|
||||
}`
|
||||
|
||||
return (
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-6 bg-card border border-border">
|
||||
{/* Direct: Overview */}
|
||||
<TabsTrigger value="overview" className={triggerActiveClass}>
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Direct: VMs & LXCs — first-class because Proxmox IS
|
||||
a hypervisor; workloads belong at top level. */}
|
||||
<TabsTrigger value="vms" className={triggerActiveClass}>
|
||||
<Boxes className="mr-2 h-4 w-4" />
|
||||
VMs & LXCs
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Dropdown: Node (Storage / Network / Hardware) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={dropdownBtnClass(isNodeActive)}>
|
||||
<NodeTriggerIcon className="mr-2 h-4 w-4" />
|
||||
{NodeTriggerLabel}
|
||||
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[180px]">
|
||||
{NODE_ITEMS.map(({ value, label, Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => setActiveTab(value)}
|
||||
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Direct: Backup (today: Host Backup only). When VM/LXC
|
||||
backup ships this becomes a dropdown. */}
|
||||
<TabsTrigger value="backup" className={triggerActiveClass}>
|
||||
<DatabaseBackup className="mr-2 h-4 w-4" />
|
||||
Backup
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Direct: Terminal */}
|
||||
<TabsTrigger value="terminal" className={triggerActiveClass}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={dropdownBtnClass(isAdminActive)}>
|
||||
<AdminTriggerIcon className="mr-2 h-4 w-4" />
|
||||
{AdminTriggerLabel}
|
||||
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[180px]">
|
||||
{ADMIN_ITEMS.map(({ value, label, Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => setActiveTab(value)}
|
||||
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TabsList>
|
||||
)
|
||||
})()}
|
||||
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<div className="lg:hidden">
|
||||
@@ -646,158 +704,74 @@ export function ProxmoxDashboard() {
|
||||
</SheetTrigger>
|
||||
</div>
|
||||
<SheetContent side="top" className="bg-card border-border">
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("overview")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "overview"
|
||||
{(() => {
|
||||
// Sheet items mirror the desktop layout: 6 sections,
|
||||
// with two of them (Node, Admin) collapsing into a
|
||||
// header + nested items. Direct tabs (Overview, VMs,
|
||||
// Backup, Terminal) sit at the top level.
|
||||
const select = (v: string) => {
|
||||
setActiveTab(v)
|
||||
setMobileMenuOpen(false)
|
||||
}
|
||||
const itemClass = (active: boolean) =>
|
||||
`w-full justify-start gap-3 ${
|
||||
active
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("storage")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "storage"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("network")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "network"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<NetworkIcon className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("vms")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "vms"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Box className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("hardware")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "hardware"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("logs")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "logs"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("terminal")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "terminal"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("security")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "security"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<span>Security</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("settings")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "settings"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("about")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "about"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
<span>About</span>
|
||||
</Button>
|
||||
</div>
|
||||
}`
|
||||
// Mobile sheet is a flat list (no section headers).
|
||||
// The desktop layout uses dropdowns to express the
|
||||
// Node/Admin grouping; here we just enumerate items
|
||||
// in the same visual order.
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mt-4">
|
||||
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
|
||||
<Boxes className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
|
||||
<NetworkIcon className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
|
||||
<DatabaseBackup className="h-5 w-5" />
|
||||
<span>Backup</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
|
||||
<ScrollText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<span>Security</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
|
||||
<Info className="h-5 w-5" />
|
||||
<span>About</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Tabs>
|
||||
@@ -830,6 +804,10 @@ export function ProxmoxDashboard() {
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||
<HostBackup key={`backup-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user