Merge develop and preserve Slovak Monitor and CLI messages

This commit is contained in:
VAIO73
2026-10-22 04:44:42 +00:00
94 changed files with 11043 additions and 1595 deletions
+45 -44
View File
@@ -48,6 +48,7 @@ import {
import { ScriptTerminalModal } from "./script-terminal-modal"
import { RestoreProgressCard } from "./restore-progress-card"
import { fetchApi, getApiUrl } from "../lib/api-config"
import { hostBackupErrorText } from "../lib/host-backup-messages"
import { fetchTerminalTicket } from "../lib/terminal-ws"
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
import { getStorageUsageColor } from "../lib/storage-usage-color"
@@ -525,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
await mutateInfo()
closeUpload()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setErr(hostBackupErrorText(e, t))
setBusy(false)
}
}
@@ -542,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
await mutateInfo()
closeDelete()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setErr(hostBackupErrorText(e, t))
setBusy(false)
}
}
@@ -765,7 +766,7 @@ function KeyfileActionsBar({
setPass1("")
setPass2("")
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setErr(hostBackupErrorText(e, t))
} finally {
setBusy(false)
}
@@ -956,7 +957,7 @@ export function HostBackup() {
mutateJobs()
setJobToDelete(null)
} catch (e) {
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` }))
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` }))
} finally {
setBusyJobId(null)
}
@@ -1334,7 +1335,7 @@ export function HostBackup() {
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
{remoteArchivesResp.errors.map((e, i) => (
<div key={i} className="font-mono break-all">
{e.backend}/{e.repo_name}: {e.error}
{e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
</div>
))}
</div>
@@ -1761,7 +1762,7 @@ function InspectModal({
setImportFile(null)
setImportPath("")
} catch (e) {
setImportError(e instanceof Error ? e.message : String(e))
setImportError(hostBackupErrorText(e, t))
} finally {
setImporting(false)
}
@@ -1812,7 +1813,7 @@ function InspectModal({
: undefined,
})
} catch (e) {
setRestoreError(e instanceof Error ? e.message : String(e))
setRestoreError(hostBackupErrorText(e, t))
} finally {
setRestorePreparing(false)
}
@@ -1841,7 +1842,7 @@ function InspectModal({
a.click()
document.body.removeChild(a)
} catch (e) {
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
} finally {
// The download itself runs in the browser's network stack;
// we just initiated it. Clear the spinner immediately.
@@ -1914,7 +1915,7 @@ function InspectModal({
document.body.removeChild(a)
setExportTask(null)
} catch (e) {
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
} finally {
setDownloading(false)
}
@@ -1962,7 +1963,7 @@ function InspectModal({
setShowDeleteArchiveConfirm(false)
if (onDeleted) onDeleted(); else onClose()
} catch (e) {
setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) }))
setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) }))
} finally {
setDeletingArchive(false)
}
@@ -1989,7 +1990,7 @@ function InspectModal({
) as NonNullable<typeof instructions>
setInstructions(res)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setFetchingInstructions(false)
}
@@ -2021,7 +2022,7 @@ function InspectModal({
)
setReport(res)
} catch (e: any) {
setError(e?.message || t("backup.errors.preflightFailed"))
setError(hostBackupErrorText(e, t))
} finally {
setRunning(false)
}
@@ -2164,7 +2165,7 @@ function InspectModal({
<span className="text-muted-foreground">— {exportTask.message}</span>
</div>
{exportTask.state === "failed" && exportTask.error && (
<div className="text-red-500 mt-1">{exportTask.error}</div>
<div className="text-red-500 mt-1">{hostBackupErrorText(exportTask.error, t)}</div>
)}
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
<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>
<span className="text-muted-foreground ml-2">{hostBackupErrorText(c.message, t)}</span>
</div>
</div>
)
@@ -3326,9 +3327,9 @@ function CreateJobDialog({
} catch (e) {
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
const detail = err.body?.tool_output
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
: err.message
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
: hostBackupErrorText(err, t)
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
setPbsImportBusy(false)
setSubmitting(false)
return
@@ -3391,7 +3392,7 @@ function CreateJobDialog({
})
onCreated()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setSubmitting(false)
}
@@ -3764,7 +3765,7 @@ function CreateJobDialog({
</>
) : (
<div className="text-red-500">
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {hostBackupErrorText(calendarPreview.error, t)}
</div>
)
) : (
@@ -4696,9 +4697,9 @@ function ManualBackupDialog({
} catch (e) {
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
const detail = err.body?.tool_output
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
: err.message
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
: hostBackupErrorText(err, t)
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
setPbsImportBusy(false)
setSubmitting(false)
return
@@ -4738,7 +4739,7 @@ function ManualBackupDialog({
)
onLaunched(resp.job_id)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setSubmitting(false)
}
@@ -5542,7 +5543,7 @@ function DestinationsSection({
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -5554,7 +5555,7 @@ function DestinationsSection({
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -5571,7 +5572,7 @@ function DestinationsSection({
)
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -5599,7 +5600,7 @@ function DestinationsSection({
})
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -6042,7 +6043,7 @@ function DestinationRow({
{capacity?.error && (
<p className="mt-3 text-[11px] text-muted-foreground italic">
{t("backup.destinations.capacityUnavailable", { error: capacity.error })}
{t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
</p>
)}
{!capacity && (
@@ -6341,7 +6342,7 @@ function AddDestinationDialog({
)
setGeneratedKey(resp)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setGeneratingKey(false)
}
@@ -6405,7 +6406,7 @@ function AddDestinationDialog({
}
onSaved(savedRepo)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setSubmitting(false)
}
@@ -6756,7 +6757,7 @@ function UsbPicker({
mutate()
if (resp?.mountpoint) onPick(resp.mountpoint)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -6784,7 +6785,7 @@ function UsbPicker({
setFormatTyped("")
if (resp?.mountpoint) onPick(resp.mountpoint)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -7001,7 +7002,7 @@ function ExtraPathsSection() {
setNewPath("")
mutate()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setSubmitting(false)
}
@@ -7014,7 +7015,7 @@ function ExtraPathsSection() {
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
mutate()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyPath(null)
}
@@ -7180,7 +7181,7 @@ function UsbDrivesSection() {
})
mutate()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -7198,7 +7199,7 @@ function UsbDrivesSection() {
})
mutate()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -7222,7 +7223,7 @@ function UsbDrivesSection() {
setFormatTarget(null)
setFormatTyped("")
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -7508,7 +7509,7 @@ function JobDetailModal({
setRunning(false)
setRunBaseline(null)
setRunBaselineLogPath(null)
setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) }))
setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) }))
}
}
@@ -7522,7 +7523,7 @@ function JobDetailModal({
await refetch()
onChanged()
} catch (e) {
setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) }))
setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) }))
} finally {
setBusy("")
}
@@ -8155,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({
})
onRecovered()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(hostBackupErrorText(e, t))
} finally {
setBusy(false)
}
@@ -8342,7 +8343,7 @@ function ArchiveContentsModal({
body: JSON.stringify(body),
})
.then((r: any) => setData(r))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.catch((e) => setError(hostBackupErrorText(e, t)))
.finally(() => setLoading(false))
}, [open, source, repo_name, snapshot, path])
@@ -8571,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
<ul className="space-y-0.5 ml-4 list-disc">
{blockers.map((b, i) => (
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
<li key={i} className="text-red-400">{hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}</li>
))}
</ul>
</div>
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
<ul className="space-y-0.5 ml-4 list-disc">
{warnings.map((w, i) => (
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
<li key={i} className="text-amber-400">{hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}</li>
))}
</ul>
</div>
@@ -8611,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
<div className="min-w-0 flex-1">
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
{e.message && <div className="text-muted-foreground text-[11px]">{hostBackupErrorText(e.message, t)}</div>}
</div>
</li>
)
+12 -11
View File
@@ -37,6 +37,7 @@ import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } fr
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { getCategoryLabel } from "../lib/category-label"
import { useT } from "@/lib/i18n/provider"
import { localizeAppEditorError } from "../lib/lxc-app-editor-errors"
// installed_via is optional now — an empty value means "register only,
// no version tracking, no warnings, just a clickable link". Docker
@@ -728,7 +729,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
if (lower.startsWith("registry unreachable")) {
return t("vmLxc.appEditor.upstreamErrorRegistry")
}
return msg
return localizeAppEditorError(msg, t)
}
const editorOpen = !!editing && editing.targetVmid === vmid
@@ -1212,7 +1213,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
}
const removeOne = async (appId: string) => {
if (!confirm("Remove this application from the CT's App tab?")) return
if (!confirm(t("vmLxc.appEditor.confirmRemove"))) return
setBusyAppId(appId)
setError(null)
try {
@@ -2069,14 +2070,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
id="app-package"
value={draft.package || ""}
onChange={(e) => setField({ package: e.target.value })}
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
placeholder={t("vmLxc.appEditor.packageExample", { package: method === "dpkg" ? "jellyfin-server" : "tailscale" })}
/>
<div className="text-[10px] text-muted-foreground mt-1">
Auto-filled from Name. Verify with{" "}
{t("vmLxc.appEditor.packageHelpBefore")}{" "}
<code className="text-foreground/70">
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
</code>{" "}
inside the CT.
{t("vmLxc.appEditor.packageHelpAfter")}
</div>
</div>
)}
@@ -2092,8 +2093,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
Absolute path. Find it with{" "}
<code className="text-foreground/70">which &lt;app&gt;</code> or{" "}
{t("vmLxc.appEditor.binaryHelpBefore")}{" "}
<code className="text-foreground/70">which &lt;app&gt;</code>{" "}{t("vmLxc.appEditor.binaryHelpOr")}{" "}
<code className="text-foreground/70">systemctl show &lt;service&gt; -p ExecStart</code>.
</div>
</div>
@@ -2558,7 +2559,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{detectorTest.installed.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.installed.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
{detectorTest.installed.method || "—"}
@@ -2574,7 +2575,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
) : detectorTest.upstream.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.upstream.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
{detectorTest.upstream.type && (
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
@@ -2587,7 +2588,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<span>{localizeAppEditorError(error, t)}</span>
</div>
)}
@@ -3343,7 +3344,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<span>{localizeAppEditorError(error, t)}</span>
</div>
)}
</div>
+13 -9
View File
@@ -14,6 +14,7 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi } from "../lib/api-config"
import { monitorLinkError } from "../lib/monitor-link-errors"
import { useT } from "../lib/i18n/provider"
interface MonitorLink {
@@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
await refreshCache("/api/monitor-links")
setOfferOrigin(false)
} catch (err) {
await refreshCache("/api/monitor-links")
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
setAddError(monitorLinkError(err, t))
try { await refreshCache("/api/monitor-links") }
catch { /* SWR shows the load error; keep the localized mutation error. */ }
} finally { setAdding(false) }
}
const notNow = () => {
@@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
</DropdownMenu>
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
@@ -290,7 +292,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const t = useT()
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
const [addresses, setAddresses] = useState<Record<string, string>>({})
const [editing, setEditing] = useState<string | null>(null)
@@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
await mutate()
await refreshCache("/api/monitor-links")
} catch (err) {
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
setError(monitorLinkError(err, t))
} finally { setBusy(false) }
}
const send = (path: string, method: string, body?: unknown) =>
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
@@ -320,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
<div className="space-y-1.5">
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
@@ -350,6 +352,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<div className="space-y-3">
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
{data?.links.map(link => {
const own = link.manual || link.custom_url
@@ -378,8 +382,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
</div>
: isEditing
? <div className="flex gap-2">
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
? <div className="grid grid-cols-2 gap-2 sm:flex">
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
+8 -5
View File
@@ -4,7 +4,8 @@ import { CalendarClock, Database } from "lucide-react"
import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { formatStorage } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { useT, useI18n } from "../lib/i18n/provider"
import { PBSProblemMessage } from "./pbs-problem-message"
export interface PBSDatastore {
name: string
@@ -13,6 +14,7 @@ export interface PBSDatastore {
used?: number | null
avail?: number | null
error?: string
error_code?: string
"backend-type"?: string
"estimated-full-date"?: number
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
@@ -42,7 +44,7 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
<span className="font-medium text-foreground truncate">{store.name}</span>
<span className="text-xs text-muted-foreground">—</span>
</div>
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
<PBSProblemMessage className="text-xs text-amber-500" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
</div>
}
const tone = usageTone(percent)
@@ -62,10 +64,11 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
const t = useT()
const { language } = useI18n()
const percent = datastorePercent(store)
const tone = percent == null ? null : usageTone(percent)
const fullDate = store["estimated-full-date"]
const type = store["backend-type"] || "datastore"
const type = store["backend-type"] || t("pbs.datastoreType")
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
@@ -99,13 +102,13 @@ export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
<PBSProblemMessage className="text-sm text-muted-foreground" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
)}
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
{fullDate !== undefined && <span className="flex items-center">
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString(language) : t("pbs.estimatedFullUnknown")}</span>
</span>}
</div>}
</div>
+58 -32
View File
@@ -14,8 +14,10 @@ import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
import { PBSProblemMessage } from "./pbs-problem-message"
import { pbsDiagnostic, pbsResultText, pbsServiceState, pbsTaskType, formatPBSDuration } from "../lib/pbs-messages"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { useT, useI18n } from "../lib/i18n/provider"
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
interface Detected { name: string; url: string; fingerprint: string }
@@ -26,6 +28,7 @@ interface PBSStatus {
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
datastores: PBSDatastore[] | null; tasks: Task[] | null;
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
error_details?: Record<string, { code: string; params?: Record<string, unknown> }>;
}
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
@@ -46,9 +49,10 @@ const TASK_ICONS: Record<string, typeof Activity> = {
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
}
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
// PBS reports "unknown", "OK", "WARNINGS: n" or an error; no state is still running.
function taskState(task: Task): keyof typeof badgeTone {
if (!task.status) return task.endtime ? "muted" : "running"
if (task.status === "unknown") return "muted"
if (task.status === "OK") return "ok"
if (task.status.startsWith("WARNINGS")) return "warn"
return "error"
@@ -56,27 +60,19 @@ function taskState(task: Task): keyof typeof badgeTone {
// PBS reports "OK", "WARNINGS: n" or the error text of the last run.
function jobState(state?: string): keyof typeof badgeTone {
if (!state) return "muted"
if (!state || state === "unknown") return "muted"
if (state.toLowerCase() === "ok") return "ok"
if (state.toLowerCase().startsWith("warn")) return "warn"
return "error"
}
function formatDuration(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "—"
if (seconds < 60) return `${Math.round(seconds)}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
const hours = Math.floor(minutes / 60)
return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
}
// `localOnly` is the PBS host's own Monitor: no connection picker, the
// header Refresh already reloads the page data. Its tabs show one `view` each.
export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
const showStores = view !== "tasks"
const showTasks = view !== "datastores"
const t = useT()
const { language } = useI18n()
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
const [selected, setSelected] = useState("")
@@ -85,7 +81,8 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
const [method, setMethod] = useState<Method>("password")
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState("")
const [error, setError] = useState<unknown>(null)
const [errorFallback, setErrorFallback] = useState("pbs.saveFailed")
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
@@ -102,7 +99,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
}
const save = async () => {
setErrorFallback("pbs.saveFailed")
setBusy(true); setError(""); setMessage("")
let saved = false
try {
// A password is sent once to create a read-only token; only the token is kept.
const { username, password, ...connection } = form
@@ -113,22 +112,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
}) })
saved = true
await reloadConnections()
setSelected(result.id)
setForm(emptyForm)
setManage(false)
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
} catch (err) { setError(saved ? { body: { code: "pbs_saved_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
finally { setBusy(false) }
}
const remove = async () => {
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
setErrorFallback("pbs.removeFailed")
setBusy(true); setError(""); setMessage("")
let removed = false
try {
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
removed = true
setForm(emptyForm)
await reloadConnections()
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
} catch (err) { setError(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
finally { setBusy(false) }
}
const now = Date.now() / 1000
@@ -150,8 +153,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
</Button>
</div>}
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
{(connectionError || statusError) && <div role="alert" className="text-sm text-red-500">
<PBSProblemMessage problem={connectionError || statusError} raw={pbsDiagnostic(connectionError || statusError)} />
</div>}
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
@@ -171,7 +175,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
</CardContent>
</Card>}
{!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<DialogHeader>
<DialogTitle>{t("pbs.manage")}</DialogTitle>
<DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
@@ -201,19 +205,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
</Button>)}
</div>
</div>}
<div className="grid gap-4 md:grid-cols-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
const secret = field === "token_secret" || field === "password"
return <div key={field} className="space-y-2">
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp"
: field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret"
? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined
return <div key={field} className="min-w-0 space-y-2">
<Label htmlFor={`pbs-${field}`} className="block leading-5">{t(`pbs.${field}`)}</Label>
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
aria-describedby={help ? `pbs-${field}-help` : undefined}
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
{help && <p id={`pbs-${field}-help`} className="text-xs text-muted-foreground">{t(help)}</p>}
</div>
})}
</div>
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
{!!error && <div role="alert" className="text-sm text-red-500">
<PBSProblemMessage problem={error} raw={pbsDiagnostic(error)} fallback={errorFallback} />
</div>}
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
<div className="flex flex-wrap justify-end gap-2">
{form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
@@ -230,14 +241,17 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
</Dialog>}
{data && <>
{!localOnly && <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })}</span>
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {pbsServiceState(state, t)}</Badge>)}
</div>}
{Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks)
.map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
.map(([section, detail]) => <div key={section} role="alert" className="text-sm text-amber-500">
<p className="font-medium">{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}</p>
<PBSProblemMessage problem={data.error_details?.[section]} raw={detail} />
</div>)}
{showStores && <Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
@@ -268,14 +282,14 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
<td className="py-2 pr-3"><span className="flex items-center gap-2">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-foreground">{row.worker_type}</span>
<span className="text-foreground" title={row.worker_type}>{pbsTaskType(row.worker_type, t)}</span>
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
</span></td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
{state === "running" ? t("pbs.running") : row.status || "—"}
</Badge></td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"}</td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"}</td>
<td className="py-2">
<PBSResultBadge status={row.status} running={state === "running"} tone={badgeTone[state]} />
</td>
</tr>
})}</tbody>
</table></div>}
@@ -299,7 +313,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
<td className="py-2"><PBSResultBadge status={row["last-run-state"]} tone={badgeTone[jobState(row["last-run-state"])]} /></td>
</tr>)}</tbody>
</table></div>
})()}
@@ -309,3 +323,15 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
</>}
</div>
}
function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) {
const t = useT()
const badge = <Badge variant="outline" className={`${tone} max-w-[16rem] truncate`}>
{running ? t("pbs.running") : pbsResultText(status, t)}
</Badge>
if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge
return <details className="max-w-xs">
<summary className="cursor-pointer" aria-label={t("pbs.technicalDetails")}>{badge}</summary>
<pre className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">{status}</pre>
</details>
}
@@ -0,0 +1,19 @@
"use client"
import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages"
import { useT } from "../lib/i18n/provider"
// Known application messages are already explained in the active language.
// Preserve unrecognized external diagnostics as escaped technical details.
export function PBSProblemMessage({ problem, raw, fallback, className = "" }: {
problem?: unknown; raw?: string; fallback?: string; className?: string
}) {
const t = useT()
return <div className={`min-w-0 space-y-1 ${className}`}>
<p>{pbsErrorText(problem, t, fallback)}</p>
{raw && !pbsIsKnownError(problem) && <details className="text-xs">
<summary className="cursor-pointer">{t("pbs.technicalDetails")}</summary>
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{raw}</pre>
</details>}
</div>
}
+22 -10
View File
@@ -31,6 +31,7 @@ import {
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { scriptCompletion, scriptCompletionOutput } from "@/lib/script-completion"
import { useT } from "../lib/i18n/provider"
interface WebInteraction {
@@ -52,6 +53,7 @@ interface ScriptTerminalModalProps {
params?: Record<string, string>
completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string
cancelledExitCode?: number
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
@@ -69,6 +71,7 @@ export function ScriptTerminalModal({
params = { EXECUTION_MODE: "web" },
completedSuccessfullyMessage,
completedWithErrorMessage,
cancelledExitCode,
onComplete,
}: ScriptTerminalModalProps) {
const t = useT()
@@ -103,10 +106,12 @@ export function ScriptTerminalModal({
const getCompletionMessage = useCallback(
(exitCode: number) =>
exitCode === 0
scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
? t("scriptTerminal.cancelled")
: exitCode === 0
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
[completedSuccessfullyMessage, completedWithErrorMessage, t],
[completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
)
const terminalContainerRef = useRef<HTMLDivElement>(null)
@@ -139,6 +144,11 @@ export function ScriptTerminalModal({
reconnectTimeoutRef.current = setTimeout(async () => {
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
if (wsRef.current) {
// Replacing a stale socket is not a script result. Its close event
// must not complete the operation before the new socket reports it.
wsRef.current.onclose = null
wsRef.current.onerror = null
wsRef.current.onmessage = null
wsRef.current.close()
}
@@ -176,6 +186,7 @@ const initMessage = {
}
ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
@@ -189,10 +200,10 @@ const initMessage = {
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
termRef.current?.write(event.data)
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -223,7 +234,7 @@ const initMessage = {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -261,7 +272,7 @@ const initMessage = {
}
}
}, 1000)
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t])
const sendKey = useCallback((key: string) => {
if (!termRef.current) return
@@ -392,6 +403,7 @@ const initMessage = {
}
ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat - don't display in terminal
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
@@ -405,10 +417,10 @@ const initMessage = {
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
term.write(event.data)
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -441,7 +453,7 @@ const initMessage = {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -814,7 +826,7 @@ const initMessage = {
)}
{(isMobile || isTablet) && (
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex flex-wrap items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button
onPointerDown={(e) => {
e.preventDefault()
+46 -17
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react"
import { Badge } from "./ui/badge"
@@ -20,6 +20,7 @@ import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import { useIsPbs } from "../lib/use-platform"
import type { LanguageCode } from "../lib/i18n/languages"
import { NavTabOrderCard } from "./nav-tab-order-card"
import { optimizationSourceError, optimizationSourceName, type OptimizationSourceError, type OptimizationSourceResponse } from "../lib/optimization-source-errors"
// GitHub Dark color palette for bash syntax highlighting
const BASH_KEYWORDS = new Set([
@@ -355,15 +356,31 @@ export function Settings() {
open: boolean
loading: boolean
toolName: string
toolKey: string
version: string
availableVersion: string
functionName: string
source: string
script: string
error: string
error: OptimizationSourceError | null
deprecated: boolean
}>({ open: false, loading: false, toolName: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: '', deprecated: false })
}>({ open: false, loading: false, toolName: '', toolKey: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: null, deprecated: false })
const [codeCopied, setCodeCopied] = useState(false)
const codeRequest = useRef(0)
const closeCodeModal = () => {
codeRequest.current += 1
setCodeModal(prev => ({ ...prev, open: false }))
setCodeCopied(false)
}
useEffect(() => () => { codeRequest.current += 1 }, [])
useEffect(() => {
if (!codeModal.open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeCodeModal()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [codeModal.open])
// Health Monitor suppression settings
const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([])
@@ -664,31 +681,39 @@ export function Settings() {
}
const viewToolSource = async (tool: ProxMenuxTool) => {
const request = ++codeRequest.current
setCodeCopied(false)
setCodeModal({
open: true,
loading: true,
toolName: tool.name,
toolKey: tool.key,
version: tool.version || '1.0',
availableVersion: tool.available_version || tool.version || '1.0',
functionName: '',
source: '',
script: '',
error: '',
error: null,
deprecated: !!tool.deprecated,
})
try {
const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`)
if (data.success) {
setCodeModal(prev => ({ ...prev, loading: false, functionName: data.function, source: data.source, script: data.script, deprecated: !!data.deprecated }))
const data = await fetchApi<OptimizationSourceResponse | null>(`/api/proxmenux/tool-source/${tool.key}`)
if (request !== codeRequest.current) return
if (data?.success === true && typeof data.source === "string" && data.source.trim()) {
const source = data.source
setCodeModal(prev => ({ ...prev, loading: false, functionName: typeof data.function === "string" ? data.function : '', source, script: typeof data.script === "string" ? data.script : '', deprecated: !!data.deprecated }))
} else {
setCodeModal(prev => ({ ...prev, loading: false, error: data.error || 'Source code not available' }))
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "unavailable", detail: typeof data?.error === "string" ? data.error : undefined } }))
}
} catch {
setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' }))
} catch (error: any) {
if (request !== codeRequest.current) return
const detail = typeof error?.body?.error === "string" ? error.body.error : undefined
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "loadFailed", detail } }))
}
}
const copySourceCode = async () => {
const request = codeRequest.current
const text = codeModal.source
let ok = false
@@ -722,9 +747,9 @@ export function Settings() {
}
}
if (ok) {
if (ok && request === codeRequest.current) {
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
setTimeout(() => { if (request === codeRequest.current) setCodeCopied(false) }, 2000)
}
}
@@ -2154,10 +2179,13 @@ export function Settings() {
{/* Code Viewer Modal */}
{codeModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ margin: 0 }} onClick={closeCodeModal}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-label={t("settings.optimizations.sourceDialogTitle", { name: optimizationSourceName(codeModal.toolKey, codeModal.toolName, t) })}
onClick={e => e.stopPropagation()}
>
{/* Header */}
@@ -2166,7 +2194,7 @@ export function Settings() {
<Code className={`h-5 w-5 flex-shrink-0 ${codeModal.deprecated ? 'text-amber-500' : 'text-orange-500'}`} />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
<h3 className="text-sm font-semibold truncate">{optimizationSourceName(codeModal.toolKey, codeModal.toolName, t)}</h3>
{codeModal.deprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
{t("settings.optimizations.legacy")}
@@ -2202,7 +2230,8 @@ export function Settings() {
</button>
)}
<button
onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}
onClick={closeCodeModal}
aria-label={t("actions.close")}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
>
<X className="h-4 w-4" />
@@ -2212,13 +2241,13 @@ export function Settings() {
{/* Body */}
<div className="flex-1 overflow-auto p-0">
{codeModal.loading ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center justify-center py-16" role="status" aria-label={t("settings.optimizations.sourceLoading")}>
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
</div>
) : codeModal.error ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Code className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">{codeModal.error}</p>
<p className="text-sm" role="alert">{optimizationSourceError(codeModal.error, t)}</p>
</div>
) : (
<pre
+20 -6
View File
@@ -7,6 +7,8 @@ import { Progress } from "./ui/progress"
import { Badge } from "./ui/badge"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
import { PBSProblemMessage } from "./pbs-problem-message"
import { pbsDiagnostic } from "../lib/pbs-messages"
import { NodeMetricsCharts } from "./node-metrics-charts"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { TemperatureDetailModal } from "./temperature-detail-modal"
@@ -187,14 +189,20 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
}
interface PBSOverviewStatus {
datastores: PBSDatastore[] | null
errors?: Record<string, string>
error_details?: Record<string, { code: string }>
}
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
const t = useT()
const { language } = useI18n()
const isPbs = product === "pbs"
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>(
const { data: pbsStatus, error: pbsError } = useSWR<PBSOverviewStatus>(
isPbs ? "/api/pbs/status?server=local" : null,
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
(url: string) => fetchApi<PBSOverviewStatus>(url),
{ refreshInterval: 60000, revalidateOnFocus: false },
)
const datastores = pbsStatus?.datastores ?? null
@@ -545,13 +553,15 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{!datastores && !pbsError ? (
{!pbsStatus && !pbsError ? (
<div className="space-y-2 animate-pulse">
<div className="h-10 bg-muted rounded w-20"></div>
<div className="h-5 bg-muted rounded w-32"></div>
</div>
) : !datastores ? (
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
<div role="alert" className="text-sm text-amber-500">
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
</div>
) : datastores.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
) : (
@@ -762,8 +772,12 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
{isPbs ? (
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
{!datastores ? (
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
{!pbsStatus && !pbsError ? (
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.loading")}</div>
) : !datastores ? (
<div role="alert" className="py-4 text-amber-500 text-sm">
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
</div>
) : datastores.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
+11 -4
View File
@@ -22,6 +22,7 @@ import useSWR from "swr"
import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { OCI_CANCELLED_EXIT_CODE, shouldRefreshOciAfterCompletion } from "@/lib/script-completion"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { GuestBackupList } from "./guest-backup-list"
import type { GuestBackup } from "../lib/guest-backup-archives"
@@ -977,7 +978,9 @@ export function VirtualMachines() {
// A container restored from a backup carries the mark of its installation
// and has no record on this host until it is recovered from the menu.
const [ociRestored, setOciRestored] = useState(false)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover" } | null>(null)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover"; recoverPrevious?: boolean } | null>(null)
const ociCompletionCodeRef = useRef<number | undefined>(undefined)
useEffect(() => { ociCompletionCodeRef.current = undefined }, [ociAction])
// Firewall log state — fetched only when the operator opens that tab
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
@@ -5515,7 +5518,7 @@ export function VirtualMachines() {
<Button
size="sm"
className={updateBtnCls}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update" })}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update", recoverPrevious: !!ociInstance.pending })}
>
{hasUpdate
? <ArrowUpCircle className="h-4 w-4 mr-1.5" />
@@ -5744,7 +5747,7 @@ export function VirtualMachines() {
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recover" })}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.recover")}
{t("vmLxc.ociUpdates.registerRestored")}
</Button>
</div>
)}
@@ -8014,6 +8017,7 @@ export function VirtualMachines() {
onClose={() => {
const vmid = ociAction.vmid
setOciAction(null)
if (!shouldRefreshOciAfterCompletion(ociCompletionCodeRef.current)) return
// The image, the digest and the record changed: read them again.
fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
.catch(() => undefined)
@@ -8027,7 +8031,10 @@ export function VirtualMachines() {
}}
scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh"
scriptName="oci_manage_instance"
title={ociAction.action === "update" ? t("vmLxc.ociUpdates.terminalTitleUpdate")
cancelledExitCode={OCI_CANCELLED_EXIT_CODE}
onComplete={(exitCode) => { ociCompletionCodeRef.current = exitCode }}
title={ociAction.action === "update" ? t(ociAction.recoverPrevious
? "vmLxc.ociUpdates.terminalTitleRecoverPrevious" : "vmLxc.ociUpdates.terminalTitleUpdate")
: ociAction.action === "recover" ? t("vmLxc.ociUpdates.terminalTitleRecover")
: t("vmLxc.ociUpdates.terminalTitleModify")}
description={t("vmLxc.ociUpdates.terminalDescription")}