mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-17 04:06:47 +00:00
Event-only modal cache + Settings edit gating + ES i18n polish
Modal caches now refresh on events only — the periodic prewarmer runs one-shot at startup, mount points split into static/runtime endpoints, and backups get a client 6-hour gate. Updates tab shows post-apply feedback and the script terminal no longer closes the parent modal. Settings adds edit gating on 3 cards with the 3-level contrast rule applied consistently. ES translation batch (~25 fixes) and What's New for 1.2.4.1-beta refreshed.
This commit is contained in:
@@ -741,6 +741,13 @@ export function HealthThresholds() {
|
||||
{t("settings.healthThresholds.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{/* Intentional exception to the global "edit mode contrast"
|
||||
rule: the numeric inputs already carry semantic colored
|
||||
backgrounds (red critical, amber warning, blue customised)
|
||||
which are meaningful and more graphical than a plain form.
|
||||
A sunken selector would either erase those tints or force
|
||||
us to `!important` every one — cleaner to keep this card
|
||||
untouched in edit mode. */}
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
|
||||
@@ -795,7 +795,7 @@ function KeyfileActionsBar({
|
||||
|
||||
{/* Current status — icon + colour by state, no truncated fp. */}
|
||||
{escrowMode !== undefined && (
|
||||
<div className="flex items-center gap-2 text-xs bg-background/40 border border-white/10 rounded px-2.5 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs bg-card border border-white/10 rounded px-2.5 py-1.5">
|
||||
<span className="font-medium text-foreground">{t("backup.keyfileManagement.uploadToPbs")}</span>
|
||||
{currentIsFull ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-400 font-medium">
|
||||
@@ -1122,7 +1122,7 @@ export function HostBackup() {
|
||||
className={`text-[10px] uppercase tracking-wide ${
|
||||
j.profile_mode === "custom"
|
||||
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
|
||||
: "border-border text-muted-foreground bg-background/40"
|
||||
: "border-border text-muted-foreground bg-card"
|
||||
}`}
|
||||
title={
|
||||
j.profile_mode === "custom"
|
||||
@@ -1322,7 +1322,7 @@ export function HostBackup() {
|
||||
key={`${u.source}:${u.display_id}`}
|
||||
type="button"
|
||||
onClick={() => setInspectingArchive(u)}
|
||||
className="w-full text-left flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-background/40 hover:bg-white/5 hover:border-blue-500/40 transition-colors group"
|
||||
className="w-full text-left flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 hover:border-blue-500/40 transition-colors group"
|
||||
title={t("backup.archives.inspectTitle")}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1986,7 +1986,7 @@ function InspectModal({
|
||||
rows mixed in only when they carry data. Backend +
|
||||
encryption badges live here (instead of the header,
|
||||
where they used to overlap the close button). */}
|
||||
<section className="rounded-md border border-border bg-background/40 p-3 space-y-1 text-xs">
|
||||
<section className="rounded-md border border-border bg-card p-3 space-y-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.archives.backup")}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -2081,7 +2081,7 @@ function InspectModal({
|
||||
|
||||
{/* In-flight export task feedback (Download for PBS/Borg). */}
|
||||
{exportTask && (
|
||||
<div className="text-[11px] space-y-1 px-3 py-2 rounded-md border border-border bg-background/40">
|
||||
<div className="text-[11px] space-y-1 px-3 py-2 rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className={`h-3.5 w-3.5 ${exportTask.state === "completed" || exportTask.state === "failed" ? "" : "animate-spin"}`} />
|
||||
<span className="font-medium">{t(`backup.taskStates.${exportTask.state}`)}</span>
|
||||
@@ -2404,7 +2404,7 @@ function InspectModal({
|
||||
: t("backup.archives.deleteBorgDescription", { repo: remoteArc?.repo_name ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
||||
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -3318,7 +3318,7 @@ function CreateJobDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<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">
|
||||
{isEdit ? (
|
||||
@@ -3388,7 +3388,7 @@ function CreateJobDialog({
|
||||
type="button"
|
||||
onClick={() => setBackend(b)}
|
||||
className={`text-left p-3 rounded-md border transition-colors ${
|
||||
backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"
|
||||
backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"
|
||||
} hover:bg-white/5`}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
@@ -3419,7 +3419,7 @@ function CreateJobDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("new")}
|
||||
className={`w-full text-left p-3 rounded-md border transition-colors ${mode === "new" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5`}
|
||||
className={`w-full text-left p-3 rounded-md border transition-colors ${mode === "new" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5`}
|
||||
>
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
@@ -3436,7 +3436,7 @@ function CreateJobDialog({
|
||||
className={`w-full text-left p-3 rounded-md border transition-colors ${
|
||||
mode === "attach" && backend !== "borg"
|
||||
? "border-blue-500 bg-blue-500/5"
|
||||
: "border-border bg-background/40"
|
||||
: "border-border bg-card"
|
||||
} ${backend === "borg" ? "opacity-60 cursor-not-allowed" : "hover:bg-white/5"}`}
|
||||
>
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
@@ -3482,7 +3482,7 @@ function CreateJobDialog({
|
||||
key={j.id}
|
||||
type="button"
|
||||
onClick={() => setPveJobId(j.id)}
|
||||
className={`w-full text-left p-3 rounded-md border ${pveJobId === j.id ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`w-full text-left p-3 rounded-md border ${pveJobId === j.id ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span className="font-mono text-xs">{j.id}</span>
|
||||
@@ -3581,7 +3581,7 @@ function CreateJobDialog({
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-mono border transition-colors ${
|
||||
active
|
||||
? "border-blue-500 bg-blue-500/10 text-blue-400"
|
||||
: "border-border bg-background/40 text-muted-foreground hover:bg-white/5"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{t(`backup.weekdays.short.${d.toLowerCase()}`)}
|
||||
@@ -3653,7 +3653,7 @@ function CreateJobDialog({
|
||||
)}
|
||||
|
||||
{/* Live preview from the backend */}
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-1 text-xs">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-1 text-xs">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.schedule.preview")}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-muted-foreground">{t("backup.fields.expressionLabel")}</span>
|
||||
@@ -3726,7 +3726,7 @@ function CreateJobDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMode("default")}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t("backup.profile.default")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -3736,7 +3736,7 @@ function CreateJobDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMode("custom")}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t("backup.profile.custom")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -4223,7 +4223,7 @@ function CreateJobDialog({
|
||||
[t("backup.retention.yearly"), String(keepYearly || "")],
|
||||
].filter(([, v]) => Number(v) > 0) as Array<[string, string]>
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-2 text-xs">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t("backup.jobs.summary")}</div>
|
||||
<div><span className="text-muted-foreground">{t("backup.fields.nameLabel")}</span> <span className="font-mono text-foreground">{jobId}</span></div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -4655,7 +4655,7 @@ function ManualBackupDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<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">
|
||||
<PlayCircle className="h-5 w-5 text-blue-500" />
|
||||
@@ -4693,7 +4693,7 @@ function ManualBackupDialog({
|
||||
key={b}
|
||||
type="button"
|
||||
onClick={() => setBackend(b)}
|
||||
className={`text-left p-3 rounded-md border ${backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -4712,7 +4712,7 @@ function ManualBackupDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMode("default")}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t("backup.profile.default")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -4722,7 +4722,7 @@ function ManualBackupDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMode("custom")}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t("backup.profile.custom")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -5166,7 +5166,7 @@ function ManualBackupDialog({
|
||||
)}
|
||||
|
||||
{/* Summary — mirrors the styling of the JobDetailModal. */}
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-2 text-xs">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t("backup.jobs.summary")}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-muted-foreground">{t("backup.fields.backendLabel")}</span>
|
||||
@@ -5683,7 +5683,7 @@ function DestinationsSection({
|
||||
: t("backup.destinations.removeDescriptionWithData")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
||||
{headline}
|
||||
</div>
|
||||
{(jobs.length > 0 || backups > 0) && (
|
||||
@@ -6021,7 +6021,7 @@ function ConfigureDestinationWizard({
|
||||
key={opt.type}
|
||||
type="button"
|
||||
onClick={() => setPicked(opt.type)}
|
||||
className={`text-left rounded-lg border-2 p-4 transition-colors bg-background/40 hover:bg-white/5 ${opt.accent}`}
|
||||
className={`text-left rounded-lg border-2 p-4 transition-colors bg-card hover:bg-white/5 ${opt.accent}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{opt.type === "pbs" ? <Server className="h-4 w-4" /> :
|
||||
@@ -6302,7 +6302,7 @@ function AddDestinationDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={type !== null} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="max-w-lg 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">
|
||||
{isEditing ? (
|
||||
@@ -6373,7 +6373,7 @@ function AddDestinationDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBorgMode("local")}
|
||||
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "local" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "local" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="font-medium flex items-center gap-1"><HardDrive className="h-3.5 w-3.5" /> {t("backup.destinations.localUsb")}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">{t("backup.destinations.localUsbDescription")}</div>
|
||||
@@ -6381,7 +6381,7 @@ function AddDestinationDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBorgMode("ssh")}
|
||||
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "ssh" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "ssh" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="font-medium flex items-center gap-1"><Server className="h-3.5 w-3.5" /> {t("backup.destinations.remoteSsh")}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">{t("backup.destinations.remoteSshDescription")}</div>
|
||||
@@ -6426,7 +6426,7 @@ function AddDestinationDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{t("backup.destinations.generateNewSshKey")}</span>
|
||||
<Button
|
||||
@@ -6474,7 +6474,7 @@ function AddDestinationDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBorgEncryptionEnabled(true)}
|
||||
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${borgEncryptionEnabled ? "border-fuchsia-500 bg-fuchsia-500/5" : "border-border bg-background/40 hover:bg-white/5"}`}
|
||||
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${borgEncryptionEnabled ? "border-fuchsia-500 bg-fuchsia-500/5" : "border-border bg-card hover:bg-white/5"}`}
|
||||
>
|
||||
<div className="font-medium">{t("backup.encryption.encryptedRepokey")}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t("backup.encryption.encryptedRepokeyDescription")}</div>
|
||||
@@ -6482,7 +6482,7 @@ function AddDestinationDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBorgEncryptionEnabled(false)}
|
||||
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${!borgEncryptionEnabled ? "border-amber-500 bg-amber-500/5" : "border-border bg-background/40 hover:bg-white/5"}`}
|
||||
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${!borgEncryptionEnabled ? "border-amber-500 bg-amber-500/5" : "border-border bg-card hover:bg-white/5"}`}
|
||||
>
|
||||
<div className="font-medium">{t("backup.encryption.noEncryption")}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t("backup.encryption.noEncryptionDescription")}</div>
|
||||
@@ -6664,7 +6664,7 @@ function UsbPicker({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
<HardDrive className="h-3.5 w-3.5 text-orange-400" />
|
||||
@@ -6789,7 +6789,7 @@ function UsbPicker({
|
||||
</DialogHeader>
|
||||
{formatTarget && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
|
||||
<div className="text-xs font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
||||
{formatTarget.path_or_device}
|
||||
{formatTarget.size && <span className="text-muted-foreground"> · {formatTarget.size}</span>}
|
||||
</div>
|
||||
@@ -6934,7 +6934,7 @@ function ExtraPathsSection() {
|
||||
{paths.map((p) => (
|
||||
<div
|
||||
key={p.path}
|
||||
className="flex items-center justify-between gap-3 p-2 rounded-md border border-border bg-background/40"
|
||||
className="flex items-center justify-between gap-3 p-2 rounded-md border border-border bg-card"
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span className="font-mono text-xs truncate" title={p.path}>{p.path}</span>
|
||||
@@ -7134,7 +7134,7 @@ function UsbDrivesSection() {
|
||||
return (
|
||||
<div
|
||||
key={`${d.state}-${d.path_or_device}-${d.uuid}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded-md border border-border bg-background/40"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded-md border border-border bg-card"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -7447,7 +7447,7 @@ function JobDetailModal({
|
||||
)}
|
||||
{!detail.enabled && (
|
||||
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
|
||||
{t("backup.status.disabled")}
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
@@ -7570,13 +7570,31 @@ function JobDetailModal({
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
|
||||
<FileSearch className="h-3.5 w-3.5" /> {t("backup.profile.title")}
|
||||
</h4>
|
||||
<Field
|
||||
icon={<Server className="h-3 w-3 text-green-500/80" />}
|
||||
label={t("backup.fields.mode")}
|
||||
value={detail.profile_mode || "—"}
|
||||
mono
|
||||
labelClassName="text-green-500/90"
|
||||
/>
|
||||
{/* Profile mode rendered as a Badge to match the
|
||||
jobs list card. Same color scheme: cyan for
|
||||
`custom`, muted for `default`. */}
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||
<span className="text-[10px] uppercase tracking-wider text-green-500/90 inline-flex items-center gap-1">
|
||||
<Server className="h-3 w-3 text-green-500/80" />
|
||||
{t("backup.fields.mode")}
|
||||
</span>
|
||||
{detail.profile_mode ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] uppercase tracking-wide ${
|
||||
detail.profile_mode === "custom"
|
||||
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
|
||||
: "border-border text-muted-foreground bg-card"
|
||||
}`}
|
||||
>
|
||||
{detail.profile_mode === "custom"
|
||||
? t("backup.profile.custom")
|
||||
: t("backup.profile.default")}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground font-mono">—</span>
|
||||
)}
|
||||
</div>
|
||||
{detail.paths && detail.paths.length > 0 && (
|
||||
<PathsDisplay paths={detail.paths} />
|
||||
)}
|
||||
@@ -7704,7 +7722,7 @@ function JobDetailModal({
|
||||
{t("backup.jobs.disableJobDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
||||
{detail?.id}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -8056,7 +8074,7 @@ function PbsKeyfileRecoveryDialog({
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="text-xs space-y-1 px-3 py-2 rounded-md border border-border bg-background/40">
|
||||
<div className="text-xs space-y-1 px-3 py-2 rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("backup.fields.sourceHost")}:</span>
|
||||
<span className="font-mono">{selected.source_host}</span>
|
||||
@@ -8313,7 +8331,7 @@ function ArchiveContentsModal({
|
||||
{Object.entries(data.metadata_files).map(([fname, content]) => (
|
||||
<div key={fname}>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-mono">{fname}</div>
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-40 overflow-auto rounded border border-border bg-background/40 p-2 text-foreground/80">
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-40 overflow-auto rounded border border-border bg-card p-2 text-foreground/80">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -8347,7 +8365,7 @@ function ContentsSection({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-background/40 p-3 space-y-2">
|
||||
<section className="rounded-md border border-border bg-card p-3 space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Icon className={`h-3.5 w-3.5 ${iconColor || "text-muted-foreground"}`} />
|
||||
{title}
|
||||
@@ -8534,7 +8552,7 @@ function FilesTree({ files, truncated }: { files: Array<{ path: string; size: nu
|
||||
placeholder={t("backup.placeholders.filterPaths")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="max-h-72 overflow-auto rounded border border-border bg-background/40">
|
||||
<div className="max-h-72 overflow-auto rounded border border-border bg-card">
|
||||
<ul className="text-[11px] font-mono divide-y divide-border/30">
|
||||
{filtered.slice(0, 2000).map((f) => (
|
||||
<li key={f.path} className="flex items-center justify-between gap-3 px-2 py-1 hover:bg-white/5">
|
||||
@@ -8917,7 +8935,7 @@ function RestoreOptionsModal({
|
||||
{t("backup.restore.blockedPathsHint")}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-border bg-background/40 p-1 max-h-72 overflow-auto">
|
||||
<div className="rounded-md border border-border bg-card p-1 max-h-72 overflow-auto">
|
||||
<ul className="divide-y divide-border/40">
|
||||
{filteredPaths.map((p) => {
|
||||
const blocked = isPathBlocked(p)
|
||||
|
||||
@@ -176,7 +176,13 @@ export function LxcUpdateDetection() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<CardContent
|
||||
className={`space-y-5${
|
||||
editMode
|
||||
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* ── Enable/Disable ── single-line label + toggle. The description
|
||||
paragraph was removed because the CardDescription above already
|
||||
covers the behaviour; on mobile that second paragraph forced
|
||||
|
||||
@@ -1359,7 +1359,13 @@ export function NotificationSettings() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<CardContent
|
||||
className={`space-y-5${
|
||||
editMode
|
||||
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* ── Service Status ── */}
|
||||
{status && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
@@ -231,14 +231,41 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
},
|
||||
}
|
||||
|
||||
// Each feature carries an i18n key so translations live in the
|
||||
// common.json catalogs and the modal renders in the user's chosen
|
||||
// locale. `text` is the English source of truth — it's what the
|
||||
// build-i18n-messages workflow feeds to Google Translate for locales
|
||||
// that haven't been curated by hand.
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
text: "One-click host update from the Health Monitor — new Update Now button in the System Updates section runs the Proxmox update flow in an in-dashboard terminal, without leaving the browser.",
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.pageSpeed",
|
||||
text: "Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
text: "In-app Install prompt for mobile — first-time visitors on Android and iOS Safari now see a bottom-sheet with clear steps for adding the Monitor to their home screen as a PWA.",
|
||||
key: "releaseNotes.currentFeatures.appTab",
|
||||
text: "New App tab inside the VM & LXC modal — especially for LXCs. Register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships.",
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.updatesTab",
|
||||
text: "Reworked Updates tab for LXCs: apply OS packages and registered-app updates from a single button, and schedule a recurring auto-update job that checks the container's OS and its tracked app on every run.",
|
||||
},
|
||||
{
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.backupNoTimeout",
|
||||
text: "Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
|
||||
},
|
||||
{
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.vmDiskUsage",
|
||||
text: "VMs running the QEMU Guest Agent (qemu-guest-agent) now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
|
||||
},
|
||||
{
|
||||
icon: <Smartphone className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.pwaInstall",
|
||||
text: "First-time visitors on Android and iOS Safari now see an in-app install prompt with clear steps for adding the Monitor to their home screen as a PWA.",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -302,7 +329,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
>
|
||||
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
||||
<p className="text-xs md:text-sm text-foreground leading-relaxed">
|
||||
{t(index === 0 ? "releaseNotes.currentFeatures.hostUpdate" : "releaseNotes.currentFeatures.mobileInstall")}
|
||||
{t(feature.key)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -333,6 +333,13 @@ export function Settings() {
|
||||
} | null>(null)
|
||||
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
|
||||
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
|
||||
// Edit-mode gating for Network Units. Matches the pattern used
|
||||
// by Health Monitor / Notifications / LxcUpdateDetection so every
|
||||
// settings card behaves the same way: view by default, edit only
|
||||
// after the user explicitly opens the pencil.
|
||||
const [unitsEditMode, setUnitsEditMode] = useState(false)
|
||||
const [pendingUnit, setPendingUnit] = useState<"Bytes" | "Bits" | null>(null)
|
||||
const [savedUnit, setSavedUnit] = useState(false)
|
||||
// Code viewer modal state. `version` is the version the user has
|
||||
// installed (read from installed_tools.json); `availableVersion` is
|
||||
// what the on-disk script declares — they differ when an update is
|
||||
@@ -363,11 +370,23 @@ export function Settings() {
|
||||
|
||||
// Remote Storage Exclusions
|
||||
const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([])
|
||||
const [storagesEditMode, setStoragesEditMode] = useState(false)
|
||||
// Pending toggle values keyed by storage.name. A row is only in
|
||||
// the map if the user flipped it while in edit mode; on Save we
|
||||
// walk the map and push each change to the backend, on Cancel we
|
||||
// drop it.
|
||||
const [pendingStorages, setPendingStorages] = useState<Map<string, { exclude_health: boolean; exclude_notifications: boolean }>>(new Map())
|
||||
const [savingStorages, setSavingStorages] = useState(false)
|
||||
const [savedStorages, setSavedStorages] = useState(false)
|
||||
const [loadingStorages, setLoadingStorages] = useState(true)
|
||||
const [savingStorage, setSavingStorage] = useState<string | null>(null)
|
||||
|
||||
// Network Interface Exclusions
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
|
||||
const [interfacesEditMode, setInterfacesEditMode] = useState(false)
|
||||
const [pendingInterfaces, setPendingInterfaces] = useState<Map<string, { exclude_health: boolean; exclude_notifications: boolean }>>(new Map())
|
||||
const [savingInterfaces, setSavingInterfaces] = useState(false)
|
||||
const [savedInterfaces, setSavedInterfaces] = useState(false)
|
||||
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
|
||||
const [savingInterface, setSavingInterface] = useState<string | null>(null)
|
||||
|
||||
@@ -648,6 +667,21 @@ export function Settings() {
|
||||
}))
|
||||
}
|
||||
|
||||
const handleCancelUnitsEdit = () => {
|
||||
setPendingUnit(null)
|
||||
setUnitsEditMode(false)
|
||||
}
|
||||
|
||||
const handleSaveUnits = () => {
|
||||
if (pendingUnit && pendingUnit !== networkUnitSettings) {
|
||||
changeNetworkUnit(pendingUnit)
|
||||
}
|
||||
setPendingUnit(null)
|
||||
setUnitsEditMode(false)
|
||||
setSavedUnit(true)
|
||||
setTimeout(() => setSavedUnit(false), 2000)
|
||||
}
|
||||
|
||||
const getUnitsSettings = () => {
|
||||
const networkUnit = getNetworkUnit()
|
||||
setNetworkUnitSettings(networkUnit)
|
||||
@@ -790,7 +824,62 @@ export function Settings() {
|
||||
setSavingInterface(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleCancelStoragesEdit = () => {
|
||||
setPendingStorages(new Map())
|
||||
setStoragesEditMode(false)
|
||||
}
|
||||
|
||||
const handleSaveStorages = async () => {
|
||||
if (pendingStorages.size === 0) {
|
||||
setStoragesEditMode(false)
|
||||
return
|
||||
}
|
||||
setSavingStorages(true)
|
||||
try {
|
||||
// Sequential to keep the UI's per-row spinner meaningful and
|
||||
// avoid hammering the backend with a burst on cards with many
|
||||
// exclusions.
|
||||
for (const [name, value] of pendingStorages.entries()) {
|
||||
const s = remoteStorages.find(x => x.name === name)
|
||||
if (!s) continue
|
||||
await handleStorageExclusionChange(name, s.type, value.exclude_health, value.exclude_notifications)
|
||||
}
|
||||
setPendingStorages(new Map())
|
||||
setStoragesEditMode(false)
|
||||
setSavedStorages(true)
|
||||
setTimeout(() => setSavedStorages(false), 2000)
|
||||
} finally {
|
||||
setSavingStorages(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelInterfacesEdit = () => {
|
||||
setPendingInterfaces(new Map())
|
||||
setInterfacesEditMode(false)
|
||||
}
|
||||
|
||||
const handleSaveInterfaces = async () => {
|
||||
if (pendingInterfaces.size === 0) {
|
||||
setInterfacesEditMode(false)
|
||||
return
|
||||
}
|
||||
setSavingInterfaces(true)
|
||||
try {
|
||||
for (const [name, value] of pendingInterfaces.entries()) {
|
||||
const iface = networkInterfaces.find(x => x.name === name)
|
||||
if (!iface) continue
|
||||
await handleInterfaceExclusionChange(name, iface.type, value.exclude_health, value.exclude_notifications)
|
||||
}
|
||||
setPendingInterfaces(new Map())
|
||||
setInterfacesEditMode(false)
|
||||
setSavedInterfaces(true)
|
||||
setTimeout(() => setSavedInterfaces(false), 2000)
|
||||
} finally {
|
||||
setSavingInterfaces(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectValue = (hours: number, key: string): string => {
|
||||
if (hours === -1) return "-1"
|
||||
const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours))
|
||||
@@ -937,7 +1026,14 @@ export function Settings() {
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t("settings.interfaceLanguage.label")}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("settings.interfaceLanguage.fallbackNote")}</p>
|
||||
{/* Only render the note when the current locale actually has
|
||||
one. Human-curated locales (en/es/sv/sk) leave the value
|
||||
empty, so the visible line disappears entirely; the
|
||||
auto-translated locales (de/fr/it/pt) surface the
|
||||
Google Translate disclaimer. */}
|
||||
{t("settings.interfaceLanguage.fallbackNote") && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("settings.interfaceLanguage.fallbackNote")}</p>
|
||||
)}
|
||||
</div>
|
||||
<Select value={language} onValueChange={(value) => setLanguage(value as LanguageCode)}>
|
||||
<SelectTrigger className="w-full sm:w-64">
|
||||
@@ -958,13 +1054,57 @@ export function Settings() {
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5 text-green-500" />
|
||||
<CardTitle>{t("settings.networkUnits.title")}</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5 text-green-500" />
|
||||
<CardTitle>{t("settings.networkUnits.title")}</CardTitle>
|
||||
</div>
|
||||
{!loadingUnitSettings && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedUnit && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{unitsEditMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancelUnitsEdit}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSaveUnits}
|
||||
disabled={pendingUnit === null || pendingUnit === networkUnitSettings}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setUnitsEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>{t("settings.networkUnits.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={
|
||||
unitsEditMode
|
||||
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loadingUnitSettings ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-green-500 border-t-transparent rounded-full" />
|
||||
@@ -972,8 +1112,12 @@ export function Settings() {
|
||||
) : (
|
||||
<div className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">{t("settings.networkUnits.label")}</div>
|
||||
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<Select
|
||||
value={pendingUnit ?? networkUnitSettings}
|
||||
onValueChange={(v) => setPendingUnit(v as "Bytes" | "Bits")}
|
||||
disabled={!unitsEditMode}
|
||||
>
|
||||
<SelectTrigger className={`w-28 h-8 text-xs ${!unitsEditMode ? "opacity-60" : ""}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1040,7 +1184,13 @@ export function Settings() {
|
||||
{t("settings.healthMonitor.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={
|
||||
healthEditMode
|
||||
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loadingHealth ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-red-500 border-t-transparent rounded-full" />
|
||||
@@ -1274,15 +1424,64 @@ export function Settings() {
|
||||
{/* Remote Storage Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-purple-500" />
|
||||
<CardTitle>{t("settings.remoteStorage.title")}</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-purple-500" />
|
||||
<CardTitle>{t("settings.remoteStorage.title")}</CardTitle>
|
||||
</div>
|
||||
{!loadingStorages && remoteStorages.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedStorages && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{storagesEditMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancelStoragesEdit}
|
||||
disabled={savingStorages}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSaveStorages}
|
||||
disabled={savingStorages || pendingStorages.size === 0}
|
||||
>
|
||||
{savingStorages ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setStoragesEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t("settings.remoteStorage.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={
|
||||
storagesEditMode
|
||||
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loadingStorages ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
@@ -1334,43 +1533,54 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!storage.exclude_health}
|
||||
onCheckedChange={(checked) => {
|
||||
handleStorageExclusionChange(
|
||||
storage.name,
|
||||
storage.type,
|
||||
!checked,
|
||||
storage.exclude_notifications
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!storage.exclude_notifications}
|
||||
onCheckedChange={(checked) => {
|
||||
handleStorageExclusionChange(
|
||||
storage.name,
|
||||
storage.type,
|
||||
storage.exclude_health,
|
||||
!checked
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
// Resolve the visible checked state from the pending
|
||||
// map first (unsaved local change) or fall back to
|
||||
// the storage's persisted value.
|
||||
const p = pendingStorages.get(storage.name)
|
||||
const excludeHealth = p ? p.exclude_health : storage.exclude_health
|
||||
const excludeNotif = p ? p.exclude_notifications : storage.exclude_notifications
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!excludeHealth}
|
||||
disabled={!storagesEditMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setPendingStorages(m => {
|
||||
const next = new Map(m)
|
||||
next.set(storage.name, { exclude_health: !checked, exclude_notifications: excludeNotif })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!storagesEditMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!excludeNotif}
|
||||
disabled={!storagesEditMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setPendingStorages(m => {
|
||||
const next = new Map(m)
|
||||
next.set(storage.name, { exclude_health: excludeHealth, exclude_notifications: !checked })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!storagesEditMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -1393,15 +1603,64 @@ export function Settings() {
|
||||
{/* Network Interface Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>{t("settings.networkInterfaces.title")}</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>{t("settings.networkInterfaces.title")}</CardTitle>
|
||||
</div>
|
||||
{!loadingInterfaces && networkInterfaces.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedInterfaces && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{interfacesEditMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancelInterfacesEdit}
|
||||
disabled={savingInterfaces}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSaveInterfaces}
|
||||
disabled={savingInterfaces || pendingInterfaces.size === 0}
|
||||
>
|
||||
{savingInterfaces ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setInterfacesEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t("settings.networkInterfaces.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={
|
||||
interfacesEditMode
|
||||
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loadingInterfaces ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
|
||||
@@ -1458,45 +1717,54 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!iface.exclude_health}
|
||||
onCheckedChange={(checked) => {
|
||||
handleInterfaceExclusionChange(
|
||||
iface.name,
|
||||
iface.type,
|
||||
!checked,
|
||||
iface.exclude_notifications
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!iface.exclude_notifications}
|
||||
onCheckedChange={(checked) => {
|
||||
handleInterfaceExclusionChange(
|
||||
iface.name,
|
||||
iface.type,
|
||||
iface.exclude_health,
|
||||
!checked
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const p = pendingInterfaces.get(iface.name)
|
||||
const excludeHealth = p ? p.exclude_health : iface.exclude_health
|
||||
const excludeNotif = p ? p.exclude_notifications : iface.exclude_notifications
|
||||
return (
|
||||
<>
|
||||
{/* Health toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!excludeHealth}
|
||||
disabled={!interfacesEditMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setPendingInterfaces(m => {
|
||||
const next = new Map(m)
|
||||
next.set(iface.name, { exclude_health: !checked, exclude_notifications: excludeNotif })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!interfacesEditMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!excludeNotif}
|
||||
disabled={!interfacesEditMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setPendingInterfaces(m => {
|
||||
const next = new Map(m)
|
||||
next.set(iface.name, { exclude_health: excludeHealth, exclude_notifications: !checked })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!interfacesEditMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Pencil, Trash2, Check, AlertTriangle } from 'lucide-react'
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Pencil, Trash2, Check, AlertTriangle, AlertCircle } from 'lucide-react'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Switch } from "./ui/switch"
|
||||
@@ -684,11 +684,30 @@ export function VirtualMachines() {
|
||||
// revalidation cheap.
|
||||
const vmModalCacheRef = useRef({
|
||||
details: new Map<number, any>(),
|
||||
backups: new Map<number, VMBackup[]>(),
|
||||
// Backups carry a `fetchedAt` millisecond timestamp alongside the
|
||||
// payload so `fetchVmBackups` can decide whether to add `?fresh=1`
|
||||
// on the wire. The backend cache is indefinite; the 6-hour gate
|
||||
// lives here on the client, matching the operator-facing rule
|
||||
// ("scheduled/cron backups can appear anywhere in the day, but a
|
||||
// 6-hour visibility lag is acceptable"). If a modal reopens
|
||||
// within 6 h of the last fetch, no re-scan on the server.
|
||||
backups: new Map<number, { backups: VMBackup[]; fetchedAt: number }>(),
|
||||
// NOTE: apps payload lives in the shared module `lxc-apps-cache`
|
||||
// (dedup between this parent and LxcAppPanel — see fetchLxcApps).
|
||||
schedule: new Map<number, any>(),
|
||||
mountPoints: new Map<number, { mount_points: LxcMountPoint[]; ad_hoc: LxcMountPoint[] }>(),
|
||||
// Only the static half of mount-points goes here. Runtime
|
||||
// (capacity/health/ad-hoc) is intentionally NOT cached — see
|
||||
// `mountPointsRuntime` state + `fetchMountPoints` for the
|
||||
// always-fresh side.
|
||||
mountPoints: new Map<number, { mount_points: LxcMountPoint[] }>(),
|
||||
// Firewall log is on-demand ONLY — do NOT seed it from the bulk
|
||||
// modal-cache endpoint or from any prewarmer. The log is a live
|
||||
// stream (new entries flow with every packet the firewall drops)
|
||||
// so cached data has no value beyond the current session; most
|
||||
// users never open the Firewall tab, so pre-scanning would waste
|
||||
// pvesh cycles for nothing. This Map fills only when
|
||||
// `fetchFirewallLog` runs (tab click or Refresh button) and
|
||||
// survives modal reopens within the same page load.
|
||||
firewall: new Map<number, any>(),
|
||||
})
|
||||
const [controlLoading, setControlLoading] = useState(false)
|
||||
@@ -702,6 +721,21 @@ export function VirtualMachines() {
|
||||
} | null>(null)
|
||||
const [confirmDestructiveTyped, setConfirmDestructiveTyped] = useState("")
|
||||
const [detailsLoading, setDetailsLoading] = useState(false)
|
||||
// Post-apply state for the Updates tab. When the script terminal
|
||||
// closes, the tab enters a "Comprobando resultado…" state until
|
||||
// a fresh /api/vms poll delivers the new update_check counts;
|
||||
// then it flashes a short success/warning banner. Prevents the
|
||||
// confusing window where stale "40 pending" numbers are still on
|
||||
// screen right after the user just ran the updater.
|
||||
// updatesRefreshing → shows a discreet spinner in the tab
|
||||
// updatesResult → { count, applied } drives the banner
|
||||
// updatesBaselineCount → snapshot of `count` at the moment the
|
||||
// apply started; the useEffect below considers the SWR poll
|
||||
// "settled" when the observed count differs from this baseline
|
||||
// (or after a 15 s safety timeout).
|
||||
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
|
||||
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
|
||||
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
const [terminalVmid, setTerminalVmid] = useState<number | null>(null)
|
||||
const [terminalVmName, setTerminalVmName] = useState<string>("")
|
||||
@@ -750,6 +784,13 @@ export function VirtualMachines() {
|
||||
const [mountPoints, setMountPoints] = useState<LxcMountPoint[]>([])
|
||||
const [adHocMounts, setAdHocMounts] = useState<LxcMountPoint[]>([])
|
||||
const [loadingMounts, setLoadingMounts] = useState(false)
|
||||
// Runtime enrichment keyed by target — fetched fresh every open
|
||||
// (never cached). `mountPoints` cards read this to fill in usage
|
||||
// bars, reachability and runtime fstype without blocking their
|
||||
// initial render. Null while the runtime fetch is in flight; the
|
||||
// static cards still render (paths, types, storage origin) and
|
||||
// reveal usage/health when the fetch resolves.
|
||||
const [mountPointsRuntime, setMountPointsRuntime] = useState<Record<string, Partial<LxcMountPoint>> | null>(null)
|
||||
|
||||
// Detect standalone mode (webapp vs browser)
|
||||
const [isStandalone, setIsStandalone] = useState(false)
|
||||
@@ -858,6 +899,48 @@ export function VirtualMachines() {
|
||||
setSelectedVM(updated)
|
||||
}, [vmData])
|
||||
|
||||
// Settle the Updates-tab "Comprobando resultado…" state as soon as
|
||||
// the /api/vms poll delivers a post-apply count that differs from
|
||||
// the baseline captured when the terminal closed. Also drops the
|
||||
// spinner after 15 s of no observed change (backend hook already
|
||||
// force-refreshed managed_installs, so a still-equal count at that
|
||||
// point means either everything was a no-op or the scan hasn't
|
||||
// finished — either way the user shouldn't keep staring at a
|
||||
// loader). Sets `updatesResult` for the transient banner: green if
|
||||
// count is now 0, amber if some packages remain.
|
||||
useEffect(() => {
|
||||
if (!updatesRefreshing) return
|
||||
if (!selectedVM) return
|
||||
const currentCount = selectedVM.update_check?.count ?? 0
|
||||
// A change from baseline (or landing at 0) means the fresh
|
||||
// post-apply snapshot is in.
|
||||
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
|
||||
const applied = Math.max(0, updatesBaselineCount - currentCount)
|
||||
setUpdatesResult({ pendingAfter: currentCount, appliedCount: applied })
|
||||
setUpdatesRefreshing(false)
|
||||
setUpdatesBaselineCount(null)
|
||||
return
|
||||
}
|
||||
// Safety timeout — never leave the spinner spinning forever.
|
||||
const safety = setTimeout(() => {
|
||||
setUpdatesResult({
|
||||
pendingAfter: currentCount,
|
||||
appliedCount: Math.max(0, (updatesBaselineCount ?? 0) - currentCount),
|
||||
})
|
||||
setUpdatesRefreshing(false)
|
||||
setUpdatesBaselineCount(null)
|
||||
}, 15000)
|
||||
return () => clearTimeout(safety)
|
||||
}, [selectedVM, updatesRefreshing, updatesBaselineCount])
|
||||
|
||||
// Auto-dismiss the post-apply banner after 6 s so it doesn't
|
||||
// clutter the tab forever.
|
||||
useEffect(() => {
|
||||
if (!updatesResult) return
|
||||
const t = setTimeout(() => setUpdatesResult(null), 6000)
|
||||
return () => clearTimeout(t)
|
||||
}, [updatesResult])
|
||||
|
||||
const handleVMClick = async (vm: VMData) => {
|
||||
setSelectedVM(vm)
|
||||
setCurrentView("main")
|
||||
@@ -880,9 +963,16 @@ export function VirtualMachines() {
|
||||
const seedBackups = cache.backups.get(vm.vmid)
|
||||
const seedMounts = cache.mountPoints.get(vm.vmid)
|
||||
setVMDetails(seedDetails ?? null)
|
||||
setVmBackups(seedBackups ?? [])
|
||||
setVmBackups(seedBackups?.backups ?? [])
|
||||
setMountPoints(seedMounts?.mount_points ?? [])
|
||||
setAdHocMounts(seedMounts?.ad_hoc ?? [])
|
||||
// Ad-hoc mounts only come from the runtime fetch — never seeded.
|
||||
// Reset to empty on each modal open; if the CT has any, they
|
||||
// appear as soon as the runtime response arrives.
|
||||
setAdHocMounts([])
|
||||
// Runtime enrichment resets too — we never carry it across opens
|
||||
// because usage/reachability go stale within seconds. Fills in
|
||||
// when `fetchMountPoints` runtime response resolves.
|
||||
setMountPointsRuntime(null)
|
||||
setDetailsLoading(!seedDetails)
|
||||
setLoadingBackups(!seedBackups)
|
||||
if (vm.type === "lxc") setLoadingMounts(!seedMounts)
|
||||
@@ -977,7 +1067,9 @@ export function VirtualMachines() {
|
||||
const cache = vmModalCacheRef.current
|
||||
for (const g of payload.guests) {
|
||||
if (g.details) cache.details.set(g.vmid, g.details)
|
||||
if (g.backups?.backups) cache.backups.set(g.vmid, g.backups.backups)
|
||||
if (g.backups?.backups) {
|
||||
cache.backups.set(g.vmid, { backups: g.backups.backups, fetchedAt: Date.now() })
|
||||
}
|
||||
if (g.type === "lxc") {
|
||||
if (g.apps) {
|
||||
// Route lxc apps through the shared module so
|
||||
@@ -988,9 +1080,11 @@ export function VirtualMachines() {
|
||||
cache.schedule.set(g.vmid, g.schedule)
|
||||
}
|
||||
if (g.mount_points?.ok) {
|
||||
// Only the static half now — ad_hoc + runtime come
|
||||
// from the always-fresh /mount-points/runtime endpoint
|
||||
// and are not seeded from the bulk payload.
|
||||
cache.mountPoints.set(g.vmid, {
|
||||
mount_points: g.mount_points.mount_points || [],
|
||||
ad_hoc: g.mount_points.ad_hoc || [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1005,27 +1099,54 @@ export function VirtualMachines() {
|
||||
}, [vmidsKey])
|
||||
|
||||
const fetchMountPoints = async (vmid: number) => {
|
||||
setLoadingMounts(true)
|
||||
// Two fetches in parallel:
|
||||
// 1) STATIC — configured mp entries + PVE classification. Backed
|
||||
// by the indefinite backend cache; cache-hit returns in ~5 ms
|
||||
// after the first load. Seeds the cards' identity + paths.
|
||||
// 2) RUNTIME — `df` capacity, `stat` reachability, ad-hoc
|
||||
// NFS/CIFS mounts done inside the CT. Never cached — always
|
||||
// hits `df`/`stat` fresh so the operator sees the live state
|
||||
// at click time. Takes 1-3s on a CT with many binds, but the
|
||||
// cards are already visible from the static payload so the
|
||||
// user perceives no lag.
|
||||
const hasSeed = mountPoints.length > 0
|
||||
if (!hasSeed) setLoadingMounts(true)
|
||||
try {
|
||||
const response = await fetchApi<{
|
||||
ok: boolean
|
||||
running: boolean
|
||||
mount_points: LxcMountPoint[]
|
||||
ad_hoc: LxcMountPoint[]
|
||||
}>(`/api/lxc/${vmid}/mount-points`)
|
||||
if (response?.ok) {
|
||||
const mp = response.mount_points || []
|
||||
const adhoc = response.ad_hoc || []
|
||||
const [staticResp, runtimeResp] = await Promise.all([
|
||||
fetchApi<{
|
||||
ok: boolean
|
||||
mount_points: LxcMountPoint[]
|
||||
}>(`/api/lxc/${vmid}/mount-points`).catch((e) => {
|
||||
console.error("Error fetching static mount points:", e)
|
||||
return null
|
||||
}),
|
||||
fetchApi<{
|
||||
ok: boolean
|
||||
running: boolean
|
||||
runtime: Record<string, Partial<LxcMountPoint>>
|
||||
ad_hoc: LxcMountPoint[]
|
||||
}>(`/api/lxc/${vmid}/mount-points/runtime`).catch((e) => {
|
||||
console.error("Error fetching runtime mount points:", e)
|
||||
return null
|
||||
}),
|
||||
])
|
||||
if (staticResp?.ok) {
|
||||
const mp = staticResp.mount_points || []
|
||||
setMountPoints(mp)
|
||||
setAdHocMounts(adhoc)
|
||||
vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp, ad_hoc: adhoc })
|
||||
} else {
|
||||
vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp })
|
||||
} else if (!hasSeed) {
|
||||
setMountPoints([])
|
||||
}
|
||||
if (runtimeResp?.ok) {
|
||||
setMountPointsRuntime(runtimeResp.runtime || {})
|
||||
setAdHocMounts(runtimeResp.ad_hoc || [])
|
||||
} else {
|
||||
setMountPointsRuntime({})
|
||||
setAdHocMounts([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching LXC mount points:", error)
|
||||
setMountPoints([])
|
||||
if (!hasSeed) setMountPoints([])
|
||||
setAdHocMounts([])
|
||||
} finally {
|
||||
setLoadingMounts(false)
|
||||
@@ -1056,18 +1177,41 @@ export function VirtualMachines() {
|
||||
}
|
||||
|
||||
const fetchVmBackups = async (vmid: number) => {
|
||||
setLoadingBackups(true)
|
||||
// Stale-while-revalidate with a 6-hour freshness gate.
|
||||
//
|
||||
// 1. If we already have backups (bulk hydration or a previous
|
||||
// open), show them IMMEDIATELY — no spinner. React diffs by
|
||||
// volid so the list never blanks; new backups slide in at
|
||||
// the top when the fresh payload arrives (sorted newest-first
|
||||
// server-side).
|
||||
// 2. If the local cache is older than 6 h, add `?fresh=1` on
|
||||
// the wire so the server bypasses its indefinite cache and
|
||||
// re-scans every storage. Otherwise the server hands back
|
||||
// the cached snapshot instantly. This is the operator's
|
||||
// accepted lag for out-of-band backups (cron / scheduled /
|
||||
// PBS retention) that don't invalidate our cache.
|
||||
// 3. Loading spinner only on the true first load.
|
||||
const GATE_MS = 6 * 60 * 60 * 1000
|
||||
const seed = vmModalCacheRef.current.backups.get(vmid)
|
||||
const isStale = !seed || (Date.now() - seed.fetchedAt) > GATE_MS
|
||||
if (!seed) setLoadingBackups(true)
|
||||
try {
|
||||
const response = await fetchApi<{ backups?: VMBackup[] }>(`/api/vms/${vmid}/backups`)
|
||||
const url = isStale
|
||||
? `/api/vms/${vmid}/backups?fresh=1`
|
||||
: `/api/vms/${vmid}/backups`
|
||||
const response = await fetchApi<{ backups?: VMBackup[] }>(url)
|
||||
if (response.backups) {
|
||||
setVmBackups(response.backups)
|
||||
vmModalCacheRef.current.backups.set(vmid, response.backups)
|
||||
vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching VM backups:", error)
|
||||
setVmBackups([])
|
||||
// Only clear the visible list if we had nothing to show
|
||||
// in the first place — a transient network hiccup must not
|
||||
// wipe the stale-but-useful view the user is looking at.
|
||||
if (!seed) setVmBackups([])
|
||||
} finally {
|
||||
setLoadingBackups(false)
|
||||
if (!seed) setLoadingBackups(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1321,7 +1465,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [applyOpen, setApplyOpen] = useState(false)
|
||||
const [applyVmid, setApplyVmid] = useState<number | null>(null)
|
||||
const [applyTarget, setApplyTarget] = useState<"os" | "app" | "both">("os")
|
||||
const [applyBackup, setApplyBackup] = useState(true)
|
||||
// Opt-in, not opt-out. Snapshot backups take time and disk, and
|
||||
// for most routine apt updates the user doesn't want to trigger
|
||||
// a vzdump — should be a deliberate choice. If a persisted schedule
|
||||
// has `backup: true` (Options card), that value overrides this
|
||||
// default when the modal opens.
|
||||
const [applyBackup, setApplyBackup] = useState(false)
|
||||
const [applyBackupStorage, setApplyBackupStorage] = useState<string>("")
|
||||
const [applyRestart, setApplyRestart] = useState(false)
|
||||
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
|
||||
@@ -1665,12 +1814,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
c.details.delete(applyVmid)
|
||||
c.schedule.delete(applyVmid)
|
||||
invalidateLxcApps(applyVmid)
|
||||
// Backend's POST /applied handler already force-refreshes the
|
||||
// managed_installs snapshot, so the next natural /api/vms poll
|
||||
// (every 2.5s via SWR refreshInterval) picks up the post-update
|
||||
// counts on its own. We deliberately avoid mutate() or explicit
|
||||
// fetch here — those trigger re-render cascades that can close
|
||||
// the parent modal.
|
||||
// Enter the "Comprobando resultado…" state on the Updates tab.
|
||||
// Baseline snapshot lets a downstream useEffect detect when the
|
||||
// SWR poll actually delivers the post-apply counts (as opposed
|
||||
// to seeing the same stale count still in flight). Was safe
|
||||
// before to skip this because the parent modal closed with the
|
||||
// terminal; now that we keep it open on purpose, the user would
|
||||
// otherwise be staring at "40 pending" right after applying.
|
||||
setUpdatesBaselineCount(selectedVM?.update_check?.count ?? 0)
|
||||
setUpdatesResult(null)
|
||||
setUpdatesRefreshing(true)
|
||||
// Force an immediate SWR revalidation instead of waiting up to
|
||||
// 2.5 s for the natural poll — the sooner the new counts land,
|
||||
// the sooner the banner appears. mutate() is safe now: the
|
||||
// parent Dialog's onOpenChange guard swallows any spurious close
|
||||
// events triggered by re-render cascades (see the Dialog guard
|
||||
// in the JSX below).
|
||||
void mutate()
|
||||
}
|
||||
|
||||
// Render the "📦 N updates / 🛡 N security" badge next to an LXC in
|
||||
@@ -2014,13 +2174,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {total}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{running} {t("vmLxc.running")}
|
||||
{t("overview.runningCount", { count: running })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-1 flex-wrap">
|
||||
{vms > 0 && (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{vms} {t("vmLxc.vms")}
|
||||
{t("overview.vmsCount", { count: vms })}
|
||||
</Badge>
|
||||
)}
|
||||
{lxc > 0 && (
|
||||
@@ -2028,7 +2188,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
{stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
||||
{stopped} {t("vmLxc.stopped")}
|
||||
{t("overview.stoppedCount", { count: stopped })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -2498,7 +2658,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
<Dialog
|
||||
open={!!selectedVM}
|
||||
onOpenChange={() => {
|
||||
onOpenChange={(open) => {
|
||||
// Radix fires `onOpenChange(false)` for backdrop clicks,
|
||||
// ESC keys AND — critically on mobile — pointer events
|
||||
// that bubbled from a nested Dialog (script terminal,
|
||||
// LXC terminal). When any of those children is on top
|
||||
// we swallow the parent-close request; closing the child
|
||||
// must not chain-close the LXC/VM modal underneath.
|
||||
// Reported after applying an update from a phone: the
|
||||
// user tapped the terminal's Close button and got kicked
|
||||
// all the way back to the guest list.
|
||||
if (open) return
|
||||
if (applyOpen || terminalOpen) return
|
||||
setSelectedVM(null)
|
||||
setVMDetails(null)
|
||||
setCurrentView("main")
|
||||
@@ -2512,11 +2683,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
>
|
||||
<DialogContent
|
||||
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
||||
}`}
|
||||
key={selectedVM?.vmid || "no-vm"}
|
||||
// Belt and braces: while a nested terminal Dialog is on
|
||||
// top, ignore backdrop clicks and ESC entirely on THIS
|
||||
// parent Dialog. onOpenChange above already guards against
|
||||
// the child-bubble path; these two guards close the
|
||||
// remaining vectors (mobile tap slop reaching the parent's
|
||||
// scrim, ESC not consumed by the child) at the DOM level.
|
||||
onInteractOutside={(e) => {
|
||||
if (applyOpen || terminalOpen) e.preventDefault()
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (applyOpen || terminalOpen) e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{currentView === "main" ? (
|
||||
<>
|
||||
@@ -3696,6 +3879,54 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{activeModalTab === "updates" && selectedVM?.type === "lxc" && (
|
||||
<div className="space-y-4" key={`updates-${selectedVM.vmid}`}>
|
||||
{/* Post-apply feedback strip.
|
||||
Refreshing: transient loader while the /api/vms
|
||||
poll delivers the new update_check counts.
|
||||
Result: green banner if 0 pending, amber if any
|
||||
packages didn't apply. Auto-dismisses after 6s
|
||||
so the tab returns to its normal look.
|
||||
The rest of the tab (branches 0/1/2 below) still
|
||||
renders during both states so the user retains the
|
||||
context — the strip sits on top as a header, it
|
||||
doesn't replace the content. */}
|
||||
{updatesRefreshing && (
|
||||
<Card className="border-blue-500/30 bg-blue-500/5">
|
||||
<CardContent className="p-3 flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-400" />
|
||||
<span className="text-blue-300">{t("vmLxc.updates.postApplyChecking")}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{!updatesRefreshing && updatesResult && updatesResult.pendingAfter === 0 && (
|
||||
<Card className="border-emerald-500/30 bg-emerald-500/5">
|
||||
<CardContent className="p-3 flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-emerald-300">
|
||||
{updatesResult.appliedCount > 0
|
||||
? t("vmLxc.updates.postApplyAllOk", { count: updatesResult.appliedCount })
|
||||
: t("vmLxc.updates.postApplyNothingPending")}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{!updatesRefreshing && updatesResult && updatesResult.pendingAfter > 0 && (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5">
|
||||
<CardContent className="p-3 flex items-start gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-amber-300 font-medium">
|
||||
{t("vmLxc.updates.postApplyPartial", { pending: updatesResult.pendingAfter })}
|
||||
</div>
|
||||
{updatesResult.appliedCount > 0 && (
|
||||
<div className="text-amber-300/80 text-xs">
|
||||
{t("vmLxc.updates.postApplyPartialSubline", { applied: updatesResult.appliedCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Branch 0 — ProxMenux-managed OCI app (Secure Gateway).
|
||||
Same state + Update button as the App tab so
|
||||
the two panels never disagree, and either
|
||||
@@ -4024,7 +4255,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.updates.installedByHelperPrefix")} <span className="text-foreground/80">{t("vmLxc.updates.helperScriptsName")}</span>
|
||||
{" "}{t("vmLxc.updates.helperUpdatesRun")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -4708,9 +4938,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{mountPoints.map((mp) => (
|
||||
<MountPointCard key={mp.mp_index || mp.target} mp={mp} />
|
||||
))}
|
||||
{/* Merge the fresh runtime enrichment onto
|
||||
each static card. If runtime isn't in yet
|
||||
(fetch still in flight), the card renders
|
||||
with paths/type/classification only and
|
||||
the usage/health fields appear when the
|
||||
runtime response resolves — no flash, no
|
||||
re-mount because the key is stable. */}
|
||||
{mountPoints.map((mp) => {
|
||||
const rt = mountPointsRuntime?.[mp.target] as Partial<LxcMountPoint> | undefined
|
||||
const merged = rt ? { ...mp, ...rt } : mp
|
||||
return <MountPointCard key={mp.mp_index || mp.target} mp={merged} />
|
||||
})}
|
||||
{adHocMounts.length > 0 && (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-muted-foreground pt-2 border-t border-border">
|
||||
|
||||
Reference in New Issue
Block a user