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:
MacRimi
2026-08-14 20:17:45 +02:00
parent 452902bd9a
commit 99ad69e8cf
19 changed files with 1317 additions and 490 deletions
@@ -741,6 +741,13 @@ export function HealthThresholds() {
{t("settings.healthThresholds.description")} {t("settings.healthThresholds.description")}
</CardDescription> </CardDescription>
</CardHeader> </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> <CardContent>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
+65 -47
View File
@@ -795,7 +795,7 @@ function KeyfileActionsBar({
{/* Current status — icon + colour by state, no truncated fp. */} {/* Current status — icon + colour by state, no truncated fp. */}
{escrowMode !== undefined && ( {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> <span className="font-medium text-foreground">{t("backup.keyfileManagement.uploadToPbs")}</span>
{currentIsFull ? ( {currentIsFull ? (
<span className="inline-flex items-center gap-1 text-emerald-400 font-medium"> <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 ${ className={`text-[10px] uppercase tracking-wide ${
j.profile_mode === "custom" j.profile_mode === "custom"
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5" ? "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={ title={
j.profile_mode === "custom" j.profile_mode === "custom"
@@ -1322,7 +1322,7 @@ export function HostBackup() {
key={`${u.source}:${u.display_id}`} key={`${u.source}:${u.display_id}`}
type="button" type="button"
onClick={() => setInspectingArchive(u)} 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")} title={t("backup.archives.inspectTitle")}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -1986,7 +1986,7 @@ function InspectModal({
rows mixed in only when they carry data. Backend + rows mixed in only when they carry data. Backend +
encryption badges live here (instead of the header, encryption badges live here (instead of the header,
where they used to overlap the close button). */} 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="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="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.archives.backup")}</div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -2081,7 +2081,7 @@ function InspectModal({
{/* In-flight export task feedback (Download for PBS/Borg). */} {/* In-flight export task feedback (Download for PBS/Borg). */}
{exportTask && ( {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"> <div className="flex items-center gap-2">
<Loader2 className={`h-3.5 w-3.5 ${exportTask.state === "completed" || exportTask.state === "failed" ? "" : "animate-spin"}`} /> <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> <span className="font-medium">{t(`backup.taskStates.${exportTask.state}`)}</span>
@@ -2404,7 +2404,7 @@ function InspectModal({
: t("backup.archives.deleteBorgDescription", { repo: remoteArc?.repo_name ?? "" })} : t("backup.archives.deleteBorgDescription", { repo: remoteArc?.repo_name ?? "" })}
</DialogDescription> </DialogDescription>
</DialogHeader> </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} {archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -3318,7 +3318,7 @@ function CreateJobDialog({
return ( return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}> <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> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{isEdit ? ( {isEdit ? (
@@ -3388,7 +3388,7 @@ function CreateJobDialog({
type="button" type="button"
onClick={() => setBackend(b)} onClick={() => setBackend(b)}
className={`text-left p-3 rounded-md border transition-colors ${ 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`} } hover:bg-white/5`}
> >
<div className="flex items-center gap-2 font-medium text-sm"> <div className="flex items-center gap-2 font-medium text-sm">
@@ -3419,7 +3419,7 @@ function CreateJobDialog({
<button <button
type="button" type="button"
onClick={() => setMode("new")} 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"> <div className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
@@ -3436,7 +3436,7 @@ function CreateJobDialog({
className={`w-full text-left p-3 rounded-md border transition-colors ${ className={`w-full text-left p-3 rounded-md border transition-colors ${
mode === "attach" && backend !== "borg" mode === "attach" && backend !== "borg"
? "border-blue-500 bg-blue-500/5" ? "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"}`} } ${backend === "borg" ? "opacity-60 cursor-not-allowed" : "hover:bg-white/5"}`}
> >
<div className="text-sm font-medium flex items-center gap-2"> <div className="text-sm font-medium flex items-center gap-2">
@@ -3482,7 +3482,7 @@ function CreateJobDialog({
key={j.id} key={j.id}
type="button" type="button"
onClick={() => setPveJobId(j.id)} 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"> <div className="flex items-center justify-between gap-2 flex-wrap">
<span className="font-mono text-xs">{j.id}</span> <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 ${ className={`px-3 py-1.5 rounded-md text-xs font-mono border transition-colors ${
active active
? "border-blue-500 bg-blue-500/10 text-blue-400" ? "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()}`)} {t(`backup.weekdays.short.${d.toLowerCase()}`)}
@@ -3653,7 +3653,7 @@ function CreateJobDialog({
)} )}
{/* Live preview from the backend */} {/* 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="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.schedule.preview")}</div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-muted-foreground">{t("backup.fields.expressionLabel")}</span> <span className="text-muted-foreground">{t("backup.fields.expressionLabel")}</span>
@@ -3726,7 +3726,7 @@ function CreateJobDialog({
<button <button
type="button" type="button"
onClick={() => setProfileMode("default")} 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-sm font-medium">{t("backup.profile.default")}</div>
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
@@ -3736,7 +3736,7 @@ function CreateJobDialog({
<button <button
type="button" type="button"
onClick={() => setProfileMode("custom")} 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-sm font-medium">{t("backup.profile.custom")}</div>
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
@@ -4223,7 +4223,7 @@ function CreateJobDialog({
[t("backup.retention.yearly"), String(keepYearly || "")], [t("backup.retention.yearly"), String(keepYearly || "")],
].filter(([, v]) => Number(v) > 0) as Array<[string, string]> ].filter(([, v]) => Number(v) > 0) as Array<[string, string]>
return ( 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 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><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"> <div className="flex items-center gap-2 flex-wrap">
@@ -4655,7 +4655,7 @@ function ManualBackupDialog({
return ( return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}> <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> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<PlayCircle className="h-5 w-5 text-blue-500" /> <PlayCircle className="h-5 w-5 text-blue-500" />
@@ -4693,7 +4693,7 @@ function ManualBackupDialog({
key={b} key={b}
type="button" type="button"
onClick={() => setBackend(b)} 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"> <div className="flex items-center gap-2 font-medium text-sm">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -4712,7 +4712,7 @@ function ManualBackupDialog({
<button <button
type="button" type="button"
onClick={() => setProfileMode("default")} 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-sm font-medium">{t("backup.profile.default")}</div>
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
@@ -4722,7 +4722,7 @@ function ManualBackupDialog({
<button <button
type="button" type="button"
onClick={() => setProfileMode("custom")} 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-sm font-medium">{t("backup.profile.custom")}</div>
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
@@ -5166,7 +5166,7 @@ function ManualBackupDialog({
)} )}
{/* Summary — mirrors the styling of the JobDetailModal. */} {/* 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="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t("backup.jobs.summary")}</div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-muted-foreground">{t("backup.fields.backendLabel")}</span> <span className="text-muted-foreground">{t("backup.fields.backendLabel")}</span>
@@ -5683,7 +5683,7 @@ function DestinationsSection({
: t("backup.destinations.removeDescriptionWithData")} : t("backup.destinations.removeDescriptionWithData")}
</DialogDescription> </DialogDescription>
</DialogHeader> </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} {headline}
</div> </div>
{(jobs.length > 0 || backups > 0) && ( {(jobs.length > 0 || backups > 0) && (
@@ -6021,7 +6021,7 @@ function ConfigureDestinationWizard({
key={opt.type} key={opt.type}
type="button" type="button"
onClick={() => setPicked(opt.type)} 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"> <div className="flex items-center gap-2 mb-1">
{opt.type === "pbs" ? <Server className="h-4 w-4" /> : {opt.type === "pbs" ? <Server className="h-4 w-4" /> :
@@ -6302,7 +6302,7 @@ function AddDestinationDialog({
return ( return (
<Dialog open={type !== null} onOpenChange={(v) => { if (!v) onClose() }}> <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> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{isEditing ? ( {isEditing ? (
@@ -6373,7 +6373,7 @@ function AddDestinationDialog({
<button <button
type="button" type="button"
onClick={() => setBorgMode("local")} 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="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> <div className="text-[11px] text-muted-foreground mt-0.5">{t("backup.destinations.localUsbDescription")}</div>
@@ -6381,7 +6381,7 @@ function AddDestinationDialog({
<button <button
type="button" type="button"
onClick={() => setBorgMode("ssh")} 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="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> <div className="text-[11px] text-muted-foreground mt-0.5">{t("backup.destinations.remoteSshDescription")}</div>
@@ -6426,7 +6426,7 @@ function AddDestinationDialog({
</p> </p>
</div> </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"> <div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{t("backup.destinations.generateNewSshKey")}</span> <span className="text-xs font-medium">{t("backup.destinations.generateNewSshKey")}</span>
<Button <Button
@@ -6474,7 +6474,7 @@ function AddDestinationDialog({
<button <button
type="button" type="button"
onClick={() => setBorgEncryptionEnabled(true)} 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="font-medium">{t("backup.encryption.encryptedRepokey")}</div>
<div className="text-[11px] text-muted-foreground">{t("backup.encryption.encryptedRepokeyDescription")}</div> <div className="text-[11px] text-muted-foreground">{t("backup.encryption.encryptedRepokeyDescription")}</div>
@@ -6482,7 +6482,7 @@ function AddDestinationDialog({
<button <button
type="button" type="button"
onClick={() => setBorgEncryptionEnabled(false)} 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="font-medium">{t("backup.encryption.noEncryption")}</div>
<div className="text-[11px] text-muted-foreground">{t("backup.encryption.noEncryptionDescription")}</div> <div className="text-[11px] text-muted-foreground">{t("backup.encryption.noEncryptionDescription")}</div>
@@ -6664,7 +6664,7 @@ function UsbPicker({
return ( 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 justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-medium"> <div className="flex items-center gap-2 text-xs font-medium">
<HardDrive className="h-3.5 w-3.5 text-orange-400" /> <HardDrive className="h-3.5 w-3.5 text-orange-400" />
@@ -6789,7 +6789,7 @@ function UsbPicker({
</DialogHeader> </DialogHeader>
{formatTarget && ( {formatTarget && (
<div className="space-y-2"> <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.path_or_device}
{formatTarget.size && <span className="text-muted-foreground"> · {formatTarget.size}</span>} {formatTarget.size && <span className="text-muted-foreground"> · {formatTarget.size}</span>}
</div> </div>
@@ -6934,7 +6934,7 @@ function ExtraPathsSection() {
{paths.map((p) => ( {paths.map((p) => (
<div <div
key={p.path} 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"> <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> <span className="font-mono text-xs truncate" title={p.path}>{p.path}</span>
@@ -7134,7 +7134,7 @@ function UsbDrivesSection() {
return ( return (
<div <div
key={`${d.state}-${d.path_or_device}-${d.uuid}`} 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="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -7447,7 +7447,7 @@ function JobDetailModal({
)} )}
{!detail.enabled && ( {!detail.enabled && (
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5"> <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> </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"> <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")} <FileSearch className="h-3.5 w-3.5" /> {t("backup.profile.title")}
</h4> </h4>
<Field {/* Profile mode rendered as a Badge to match the
icon={<Server className="h-3 w-3 text-green-500/80" />} jobs list card. Same color scheme: cyan for
label={t("backup.fields.mode")} `custom`, muted for `default`. */}
value={detail.profile_mode || "—"} <div className="flex items-center gap-2 flex-wrap text-xs">
mono <span className="text-[10px] uppercase tracking-wider text-green-500/90 inline-flex items-center gap-1">
labelClassName="text-green-500/90" <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 && ( {detail.paths && detail.paths.length > 0 && (
<PathsDisplay paths={detail.paths} /> <PathsDisplay paths={detail.paths} />
)} )}
@@ -7704,7 +7722,7 @@ function JobDetailModal({
{t("backup.jobs.disableJobDescription")} {t("backup.jobs.disableJobDescription")}
</DialogDescription> </DialogDescription>
</DialogHeader> </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} {detail?.id}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -8056,7 +8074,7 @@ function PbsKeyfileRecoveryDialog({
)} )}
{selected && ( {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"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">{t("backup.fields.sourceHost")}:</span> <span className="text-muted-foreground">{t("backup.fields.sourceHost")}:</span>
<span className="font-mono">{selected.source_host}</span> <span className="font-mono">{selected.source_host}</span>
@@ -8313,7 +8331,7 @@ function ArchiveContentsModal({
{Object.entries(data.metadata_files).map(([fname, content]) => ( {Object.entries(data.metadata_files).map(([fname, content]) => (
<div key={fname}> <div key={fname}>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-mono">{fname}</div> <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} {content}
</pre> </pre>
</div> </div>
@@ -8347,7 +8365,7 @@ function ContentsSection({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( 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"> <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"}`} /> <Icon className={`h-3.5 w-3.5 ${iconColor || "text-muted-foreground"}`} />
{title} {title}
@@ -8534,7 +8552,7 @@ function FilesTree({ files, truncated }: { files: Array<{ path: string; size: nu
placeholder={t("backup.placeholders.filterPaths")} placeholder={t("backup.placeholders.filterPaths")}
className="h-8 text-xs" 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"> <ul className="text-[11px] font-mono divide-y divide-border/30">
{filtered.slice(0, 2000).map((f) => ( {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"> <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")} {t("backup.restore.blockedPathsHint")}
</div> </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"> <ul className="divide-y divide-border/40">
{filteredPaths.map((p) => { {filteredPaths.map((p) => {
const blocked = isPathBlocked(p) const blocked = isPathBlocked(p)
+7 -1
View File
@@ -176,7 +176,13 @@ export function LxcUpdateDetection() {
</CardDescription> </CardDescription>
</CardHeader> </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 {/* ── Enable/Disable ── single-line label + toggle. The description
paragraph was removed because the CardDescription above already paragraph was removed because the CardDescription above already
covers the behaviour; on mobile that second paragraph forced covers the behaviour; on mobile that second paragraph forced
@@ -1359,7 +1359,13 @@ export function NotificationSettings() {
</CardDescription> </CardDescription>
</CardHeader> </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 ── */} {/* ── Service Status ── */}
{status && ( {status && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border"> <div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border">
+32 -5
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog" 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 { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider" 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 = [ const CURRENT_VERSION_FEATURES = [
{ {
icon: <RefreshCw className="h-5 w-5" />, icon: <Zap 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.", 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" />, 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> <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"> <p className="text-xs md:text-sm text-foreground leading-relaxed">
{t(index === 0 ? "releaseNotes.currentFeatures.hostUpdate" : "releaseNotes.currentFeatures.mobileInstall")} {t(feature.key)}
</p> </p>
</div> </div>
))} ))}
+361 -93
View File
@@ -333,6 +333,13 @@ export function Settings() {
} | null>(null) } | null>(null)
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes") const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true) 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 // Code viewer modal state. `version` is the version the user has
// installed (read from installed_tools.json); `availableVersion` is // installed (read from installed_tools.json); `availableVersion` is
// what the on-disk script declares — they differ when an update is // what the on-disk script declares — they differ when an update is
@@ -363,11 +370,23 @@ export function Settings() {
// Remote Storage Exclusions // Remote Storage Exclusions
const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([]) 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 [loadingStorages, setLoadingStorages] = useState(true)
const [savingStorage, setSavingStorage] = useState<string | null>(null) const [savingStorage, setSavingStorage] = useState<string | null>(null)
// Network Interface Exclusions // Network Interface Exclusions
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([]) 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 [loadingInterfaces, setLoadingInterfaces] = useState(true)
const [savingInterface, setSavingInterface] = useState<string | null>(null) 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 getUnitsSettings = () => {
const networkUnit = getNetworkUnit() const networkUnit = getNetworkUnit()
setNetworkUnitSettings(networkUnit) setNetworkUnitSettings(networkUnit)
@@ -790,7 +824,62 @@ export function Settings() {
setSavingInterface(null) 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 => { const getSelectValue = (hours: number, key: string): string => {
if (hours === -1) return "-1" if (hours === -1) return "-1"
const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours)) 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 className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<div className="text-sm font-medium text-foreground">{t("settings.interfaceLanguage.label")}</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> </div>
<Select value={language} onValueChange={(value) => setLanguage(value as LanguageCode)}> <Select value={language} onValueChange={(value) => setLanguage(value as LanguageCode)}>
<SelectTrigger className="w-full sm:w-64"> <SelectTrigger className="w-full sm:w-64">
@@ -958,13 +1054,57 @@ export function Settings() {
{/* Network Units Settings */} {/* Network Units Settings */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<Ruler className="h-5 w-5 text-green-500" /> <div className="flex items-center gap-2">
<CardTitle>{t("settings.networkUnits.title")}</CardTitle> <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> </div>
<CardDescription>{t("settings.networkUnits.description")}</CardDescription> <CardDescription>{t("settings.networkUnits.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent
className={
unitsEditMode
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
: undefined
}
>
{loadingUnitSettings ? ( {loadingUnitSettings ? (
<div className="flex items-center justify-center py-8"> <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" /> <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="text-foreground flex items-center justify-between">
<div className="flex items-center">{t("settings.networkUnits.label")}</div> <div className="flex items-center">{t("settings.networkUnits.label")}</div>
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}> <Select
<SelectTrigger className="w-28 h-8 text-xs"> value={pendingUnit ?? networkUnitSettings}
onValueChange={(v) => setPendingUnit(v as "Bytes" | "Bits")}
disabled={!unitsEditMode}
>
<SelectTrigger className={`w-28 h-8 text-xs ${!unitsEditMode ? "opacity-60" : ""}`}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1040,7 +1184,13 @@ export function Settings() {
{t("settings.healthMonitor.description")} {t("settings.healthMonitor.description")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent
className={
healthEditMode
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
: undefined
}
>
{loadingHealth ? ( {loadingHealth ? (
<div className="flex items-center justify-center py-8"> <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" /> <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 */} {/* Remote Storage Exclusions */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<Database className="h-5 w-5 text-purple-500" /> <div className="flex items-center gap-2">
<CardTitle>{t("settings.remoteStorage.title")}</CardTitle> <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> </div>
<CardDescription> <CardDescription>
{t("settings.remoteStorage.description")} {t("settings.remoteStorage.description")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent
className={
storagesEditMode
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
: undefined
}
>
{loadingStorages ? ( {loadingStorages ? (
<div className="flex items-center justify-center py-8"> <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" /> <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> </div>
<div className="flex items-center justify-center w-20"> {(() => {
{isSaving ? ( // Resolve the visible checked state from the pending
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> // map first (unsaved local change) or fall back to
) : ( // the storage's persisted value.
<Switch const p = pendingStorages.get(storage.name)
checked={!storage.exclude_health} const excludeHealth = p ? p.exclude_health : storage.exclude_health
onCheckedChange={(checked) => { const excludeNotif = p ? p.exclude_notifications : storage.exclude_notifications
handleStorageExclusionChange( return (
storage.name, <>
storage.type, <div className="flex items-center justify-center w-20">
!checked, {isSaving ? (
storage.exclude_notifications <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) ) : (
}} <Switch
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border" checked={!excludeHealth}
/> disabled={!storagesEditMode}
)} onCheckedChange={(checked) => {
</div> setPendingStorages(m => {
const next = new Map(m)
<div className="flex items-center justify-center w-20"> next.set(storage.name, { exclude_health: !checked, exclude_notifications: excludeNotif })
{isSaving ? ( return next
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> })
) : ( }}
<Switch className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!storagesEditMode ? "opacity-60" : ""}`}
checked={!storage.exclude_notifications} />
onCheckedChange={(checked) => { )}
handleStorageExclusionChange( </div>
storage.name, <div className="flex items-center justify-center w-20">
storage.type, {isSaving ? (
storage.exclude_health, <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
!checked ) : (
) <Switch
}} checked={!excludeNotif}
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border" disabled={!storagesEditMode}
/> onCheckedChange={(checked) => {
)} setPendingStorages(m => {
</div> 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> </div>
) )
})} })}
@@ -1393,15 +1603,64 @@ export function Settings() {
{/* Network Interface Exclusions */} {/* Network Interface Exclusions */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<Network className="h-5 w-5 text-blue-500" /> <div className="flex items-center gap-2">
<CardTitle>{t("settings.networkInterfaces.title")}</CardTitle> <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> </div>
<CardDescription> <CardDescription>
{t("settings.networkInterfaces.description")} {t("settings.networkInterfaces.description")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent
className={
interfacesEditMode
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
: undefined
}
>
{loadingInterfaces ? ( {loadingInterfaces ? (
<div className="flex items-center justify-center py-8"> <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" /> <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>
</div> </div>
{/* Health toggle */} {(() => {
<div className="flex justify-center w-20"> const p = pendingInterfaces.get(iface.name)
{isSaving ? ( const excludeHealth = p ? p.exclude_health : iface.exclude_health
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> const excludeNotif = p ? p.exclude_notifications : iface.exclude_notifications
) : ( return (
<Switch <>
checked={!iface.exclude_health} {/* Health toggle */}
onCheckedChange={(checked) => { <div className="flex justify-center w-20">
handleInterfaceExclusionChange( {isSaving ? (
iface.name, <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
iface.type, ) : (
!checked, <Switch
iface.exclude_notifications checked={!excludeHealth}
) disabled={!interfacesEditMode}
}} onCheckedChange={(checked) => {
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border" setPendingInterfaces(m => {
/> const next = new Map(m)
)} next.set(iface.name, { exclude_health: !checked, exclude_notifications: excludeNotif })
</div> return next
})
{/* Notifications toggle */} }}
<div className="flex justify-center w-20"> className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!interfacesEditMode ? "opacity-60" : ""}`}
{isSaving ? ( />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> )}
) : ( </div>
<Switch
checked={!iface.exclude_notifications} {/* Notifications toggle */}
onCheckedChange={(checked) => { <div className="flex justify-center w-20">
handleInterfaceExclusionChange( {isSaving ? (
iface.name, <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
iface.type, ) : (
iface.exclude_health, <Switch
!checked checked={!excludeNotif}
) disabled={!interfacesEditMode}
}} onCheckedChange={(checked) => {
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border" setPendingInterfaces(m => {
/> const next = new Map(m)
)} next.set(iface.name, { exclude_health: excludeHealth, exclude_notifications: !checked })
</div> return next
})
}}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!interfacesEditMode ? "opacity-60" : ""}`}
/>
)}
</div>
</>
)
})()}
</div> </div>
) )
})} })}
+282 -43
View File
@@ -9,7 +9,7 @@ import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress" import { Progress } from "./ui/progress"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog" 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Checkbox } from "./ui/checkbox" import { Checkbox } from "./ui/checkbox"
import { Switch } from "./ui/switch" import { Switch } from "./ui/switch"
@@ -684,11 +684,30 @@ export function VirtualMachines() {
// revalidation cheap. // revalidation cheap.
const vmModalCacheRef = useRef({ const vmModalCacheRef = useRef({
details: new Map<number, any>(), 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` // NOTE: apps payload lives in the shared module `lxc-apps-cache`
// (dedup between this parent and LxcAppPanel — see fetchLxcApps). // (dedup between this parent and LxcAppPanel — see fetchLxcApps).
schedule: new Map<number, any>(), 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>(), firewall: new Map<number, any>(),
}) })
const [controlLoading, setControlLoading] = useState(false) const [controlLoading, setControlLoading] = useState(false)
@@ -702,6 +721,21 @@ export function VirtualMachines() {
} | null>(null) } | null>(null)
const [confirmDestructiveTyped, setConfirmDestructiveTyped] = useState("") const [confirmDestructiveTyped, setConfirmDestructiveTyped] = useState("")
const [detailsLoading, setDetailsLoading] = useState(false) 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 [terminalOpen, setTerminalOpen] = useState(false)
const [terminalVmid, setTerminalVmid] = useState<number | null>(null) const [terminalVmid, setTerminalVmid] = useState<number | null>(null)
const [terminalVmName, setTerminalVmName] = useState<string>("") const [terminalVmName, setTerminalVmName] = useState<string>("")
@@ -750,6 +784,13 @@ export function VirtualMachines() {
const [mountPoints, setMountPoints] = useState<LxcMountPoint[]>([]) const [mountPoints, setMountPoints] = useState<LxcMountPoint[]>([])
const [adHocMounts, setAdHocMounts] = useState<LxcMountPoint[]>([]) const [adHocMounts, setAdHocMounts] = useState<LxcMountPoint[]>([])
const [loadingMounts, setLoadingMounts] = useState(false) 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) // Detect standalone mode (webapp vs browser)
const [isStandalone, setIsStandalone] = useState(false) const [isStandalone, setIsStandalone] = useState(false)
@@ -858,6 +899,48 @@ export function VirtualMachines() {
setSelectedVM(updated) setSelectedVM(updated)
}, [vmData]) }, [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) => { const handleVMClick = async (vm: VMData) => {
setSelectedVM(vm) setSelectedVM(vm)
setCurrentView("main") setCurrentView("main")
@@ -880,9 +963,16 @@ export function VirtualMachines() {
const seedBackups = cache.backups.get(vm.vmid) const seedBackups = cache.backups.get(vm.vmid)
const seedMounts = cache.mountPoints.get(vm.vmid) const seedMounts = cache.mountPoints.get(vm.vmid)
setVMDetails(seedDetails ?? null) setVMDetails(seedDetails ?? null)
setVmBackups(seedBackups ?? []) setVmBackups(seedBackups?.backups ?? [])
setMountPoints(seedMounts?.mount_points ?? []) 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) setDetailsLoading(!seedDetails)
setLoadingBackups(!seedBackups) setLoadingBackups(!seedBackups)
if (vm.type === "lxc") setLoadingMounts(!seedMounts) if (vm.type === "lxc") setLoadingMounts(!seedMounts)
@@ -977,7 +1067,9 @@ export function VirtualMachines() {
const cache = vmModalCacheRef.current const cache = vmModalCacheRef.current
for (const g of payload.guests) { for (const g of payload.guests) {
if (g.details) cache.details.set(g.vmid, g.details) 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.type === "lxc") {
if (g.apps) { if (g.apps) {
// Route lxc apps through the shared module so // Route lxc apps through the shared module so
@@ -988,9 +1080,11 @@ export function VirtualMachines() {
cache.schedule.set(g.vmid, g.schedule) cache.schedule.set(g.vmid, g.schedule)
} }
if (g.mount_points?.ok) { 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, { cache.mountPoints.set(g.vmid, {
mount_points: g.mount_points.mount_points || [], mount_points: g.mount_points.mount_points || [],
ad_hoc: g.mount_points.ad_hoc || [],
}) })
} }
} }
@@ -1005,27 +1099,54 @@ export function VirtualMachines() {
}, [vmidsKey]) }, [vmidsKey])
const fetchMountPoints = async (vmid: number) => { 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 { try {
const response = await fetchApi<{ const [staticResp, runtimeResp] = await Promise.all([
ok: boolean fetchApi<{
running: boolean ok: boolean
mount_points: LxcMountPoint[] mount_points: LxcMountPoint[]
ad_hoc: LxcMountPoint[] }>(`/api/lxc/${vmid}/mount-points`).catch((e) => {
}>(`/api/lxc/${vmid}/mount-points`) console.error("Error fetching static mount points:", e)
if (response?.ok) { return null
const mp = response.mount_points || [] }),
const adhoc = response.ad_hoc || [] 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) setMountPoints(mp)
setAdHocMounts(adhoc) vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp })
vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp, ad_hoc: adhoc }) } else if (!hasSeed) {
} else {
setMountPoints([]) setMountPoints([])
}
if (runtimeResp?.ok) {
setMountPointsRuntime(runtimeResp.runtime || {})
setAdHocMounts(runtimeResp.ad_hoc || [])
} else {
setMountPointsRuntime({})
setAdHocMounts([]) setAdHocMounts([])
} }
} catch (error) { } catch (error) {
console.error("Error fetching LXC mount points:", error) console.error("Error fetching LXC mount points:", error)
setMountPoints([]) if (!hasSeed) setMountPoints([])
setAdHocMounts([]) setAdHocMounts([])
} finally { } finally {
setLoadingMounts(false) setLoadingMounts(false)
@@ -1056,18 +1177,41 @@ export function VirtualMachines() {
} }
const fetchVmBackups = async (vmid: number) => { 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 { 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) { if (response.backups) {
setVmBackups(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) { } catch (error) {
console.error("Error fetching VM backups:", 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 { } finally {
setLoadingBackups(false) if (!seed) setLoadingBackups(false)
} }
} }
@@ -1321,7 +1465,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const [applyOpen, setApplyOpen] = useState(false) const [applyOpen, setApplyOpen] = useState(false)
const [applyVmid, setApplyVmid] = useState<number | null>(null) const [applyVmid, setApplyVmid] = useState<number | null>(null)
const [applyTarget, setApplyTarget] = useState<"os" | "app" | "both">("os") 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 [applyBackupStorage, setApplyBackupStorage] = useState<string>("")
const [applyRestart, setApplyRestart] = useState(false) const [applyRestart, setApplyRestart] = useState(false)
const [applyStartedAt, setApplyStartedAt] = useState<number>(0) const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
@@ -1665,12 +1814,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
c.details.delete(applyVmid) c.details.delete(applyVmid)
c.schedule.delete(applyVmid) c.schedule.delete(applyVmid)
invalidateLxcApps(applyVmid) invalidateLxcApps(applyVmid)
// Backend's POST /applied handler already force-refreshes the // Enter the "Comprobando resultado…" state on the Updates tab.
// managed_installs snapshot, so the next natural /api/vms poll // Baseline snapshot lets a downstream useEffect detect when the
// (every 2.5s via SWR refreshInterval) picks up the post-update // SWR poll actually delivers the post-apply counts (as opposed
// counts on its own. We deliberately avoid mutate() or explicit // to seeing the same stale count still in flight). Was safe
// fetch here — those trigger re-render cascades that can close // before to skip this because the parent modal closed with the
// the parent modal. // 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 // 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> <span className="text-lg font-medium ml-1 text-muted-foreground">/ {total}</span>
</div> </div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20"> <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> </Badge>
</div> </div>
<div className="mt-3 flex gap-1 flex-wrap"> <div className="mt-3 flex gap-1 flex-wrap">
{vms > 0 && ( {vms > 0 && (
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20"> <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> </Badge>
)} )}
{lxc > 0 && ( {lxc > 0 && (
@@ -2028,7 +2188,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)} )}
{stopped > 0 && ( {stopped > 0 && (
<Badge variant="outline" className="bg-muted text-muted-foreground border-border"> <Badge variant="outline" className="bg-muted text-muted-foreground border-border">
{stopped} {t("vmLxc.stopped")} {t("overview.stoppedCount", { count: stopped })}
</Badge> </Badge>
)} )}
</div> </div>
@@ -2498,7 +2658,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Dialog <Dialog
open={!!selectedVM} 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) setSelectedVM(null)
setVMDetails(null) setVMDetails(null)
setCurrentView("main") setCurrentView("main")
@@ -2512,11 +2683,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
> >
<DialogContent <DialogContent
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${ className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
isStandalone isStandalone
? "h-[95vh] sm:h-[90vh]" ? "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)]" : "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"} 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" ? ( {currentView === "main" ? (
<> <>
@@ -3696,6 +3879,54 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{activeModalTab === "updates" && selectedVM?.type === "lxc" && ( {activeModalTab === "updates" && selectedVM?.type === "lxc" && (
<div className="space-y-4" key={`updates-${selectedVM.vmid}`}> <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). {/* Branch 0 ProxMenux-managed OCI app (Secure Gateway).
Same state + Update button as the App tab so Same state + Update button as the App tab so
the two panels never disagree, and either 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"> <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.installedByHelperPrefix")} <span className="text-foreground/80">{t("vmLxc.updates.helperScriptsName")}</span>
{" "}{t("vmLxc.updates.helperUpdatesRun")}
</span> </span>
</div> </div>
)} )}
@@ -4708,9 +4938,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div> </div>
) : ( ) : (
<> <>
{mountPoints.map((mp) => ( {/* Merge the fresh runtime enrichment onto
<MountPointCard key={mp.mp_index || mp.target} mp={mp} /> 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 && ( {adHocMounts.length > 0 && (
<> <>
<div className="text-sm font-semibold text-muted-foreground pt-2 border-t border-border"> <div className="text-sm font-semibold text-muted-foreground pt-2 border-t border-border">
+13 -16
View File
@@ -1194,8 +1194,8 @@
"applyOsUpdate": "Betriebssystem-Update anwenden", "applyOsUpdate": "Betriebssystem-Update anwenden",
"osUpToDate": "Betriebssystem auf dem neuesten Stand", "osUpToDate": "Betriebssystem auf dem neuesten Stand",
"installedByHelperPrefix": "Installiert von", "installedByHelperPrefix": "Installiert von",
"helperScriptsName": "Proxmox Helper-Scripts", "helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": " Updates führen den Community-Scripts-Helfer aus.", "helperUpdatesRun": "Installiert von Proxmox Helper-Scripts.",
"installedPrefix": "installiert", "installedPrefix": "installiert",
"upstreamAvailable": "Version {version} verfügbar", "upstreamAvailable": "Version {version} verfügbar",
"upToDateAt": "Aktuell unter", "upToDateAt": "Aktuell unter",
@@ -1409,14 +1409,7 @@
"alsoDetectedContainer": "Auch auf diesem Container erkannt", "alsoDetectedContainer": "Auch auf diesem Container erkannt",
"addAnotherApplication": "Fügen Sie eine weitere Anwendung hinzu", "addAnotherApplication": "Fügen Sie eine weitere Anwendung hinzu",
"doneButton": "Erledigt", "doneButton": "Erledigt",
"editButton": "Bearbeiten", "editButton": "Bearbeiten"
"upstreamErrorTimeout": "Netzwerk-Timeout beim Kontaktieren des Upstreams",
"upstreamErrorNetwork": "Netzwerkfehler: {detail}",
"upstreamErrorGeneric": "Upstream-Prüfung fehlgeschlagen: {detail}",
"notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN zum Stummschalten klicken",
"notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet zum Aktivieren klicken",
"notifyUpstreamLabel": "Benachrichtigen Sie mich, wenn eine neue Upstream-Version verfügbar ist",
"notifyUpstreamHelp": "Sendet „app_update_available“ an die Kanäle, die in Einstellungen → Benachrichtigungen aktiviert sind.Deaktivieren Sie diese Option, wenn diese App auf Ihrer Box nicht aktualisiert werden kann."
} }
}, },
"settings": { "settings": {
@@ -1428,9 +1421,9 @@
}, },
"interfaceLanguage": { "interfaceLanguage": {
"title": "Schnittstellensprache", "title": "Schnittstellensprache",
"description": "Wählen Sie die vom Monitor-Dashboard verwendete Sprache. Fehlende Übersetzungen fallen auf Englisch zurück.", "description": "Wählen Sie die Sprache für das Monitor-Dashboard.",
"label": "Dashboard-Sprache", "label": "Dashboard-Sprache",
"fallbackNote": "Nicht übersetzter Text wird auf Englisch angezeigt, bis die Community ihn ausfüllt.", "fallbackNote": "Automatische Übersetzung mit Google Translate. Melde Fehler in den Issues des Projekts oder schicke einen PR mit der Korrektur.",
"statusComplete": "vollständig", "statusComplete": "vollständig",
"statusPartial": "teilweise", "statusPartial": "teilweise",
"statusNeedsTranslation": "Community-Übersetzung erforderlich" "statusNeedsTranslation": "Community-Übersetzung erforderlich"
@@ -1686,8 +1679,7 @@
"post_install_update": "ProxMenux-Optimierungsupdates verfügbar", "post_install_update": "ProxMenux-Optimierungsupdates verfügbar",
"secure_gateway_update_available": "Secure Gateway-Update verfügbar", "secure_gateway_update_available": "Secure Gateway-Update verfügbar",
"nvidia_driver_update_available": "NVIDIA-Treiberupdate verfügbar", "nvidia_driver_update_available": "NVIDIA-Treiberupdate verfügbar",
"coral_driver_update_available": "Update des Coral TPU-Treibers verfügbar", "coral_driver_update_available": "Update des Coral TPU-Treibers verfügbar"
"app_update_available": "App-Update verfügbar"
}, },
"ui": { "ui": {
"quietHours": "Ruhige Stunden", "quietHours": "Ruhige Stunden",
@@ -2995,7 +2987,13 @@
"dontShowAgain": "Für diese Version nicht mehr anzeigen", "dontShowAgain": "Für diese Version nicht mehr anzeigen",
"currentFeatures": { "currentFeatures": {
"hostUpdate": "Host-Update mit einem Klick über den Health Monitor. Die neue Schaltfläche „Jetzt aktualisieren“ in System Updates führt den Proxmox-Update-Flow in einem Dashboard-Terminal aus, ohne den Browser zu verlassen.", "hostUpdate": "Host-Update mit einem Klick über den Health Monitor. Die neue Schaltfläche „Jetzt aktualisieren“ in System Updates führt den Proxmox-Update-Flow in einem Dashboard-Terminal aus, ohne den Browser zu verlassen.",
"mobileInstall": "In-App-Installationsaufforderung für Mobilgeräte. Erstbesucher von Android und iOS Safari sehen jetzt einfache Schritte zum Hinzufügen des Monitors als PWA zu ihrem Startbildschirm." "mobileInstall": "In-App-Installationsaufforderung für Mobilgeräte. Erstbesucher von Android und iOS Safari sehen jetzt einfache Schritte zum Hinzufügen des Monitors als PWA zu ihrem Startbildschirm.",
"pageSpeed": "",
"appTab": "",
"updatesTab": "",
"backupNoTimeout": "",
"vmDiskUsage": "",
"pwaInstall": ""
} }
}, },
"network": { "network": {
@@ -3530,7 +3528,6 @@
"deletePbsDescription": "Dadurch wird der PBS-Snapshot aus dem Datenspeicher entfernt.", "deletePbsDescription": "Dadurch wird der PBS-Snapshot aus dem Datenspeicher entfernt.",
"deletePbsTitle": "PBS-Snapshot löschen", "deletePbsTitle": "PBS-Snapshot löschen",
"companionFile": "Begleitdatei", "companionFile": "Begleitdatei",
"descriptionAfter": "",
"descriptionBefore": "Durchsuchen Sie die gefundenen Backups und stellen Sie sie wieder her", "descriptionBefore": "Durchsuchen Sie die gefundenen Backups und stellen Sie sie wieder her",
"downloadTitle": "Laden Sie dieses Backup herunter", "downloadTitle": "Laden Sie dieses Backup herunter",
"emptyAfter": "Backups noch nicht.", "emptyAfter": "Backups noch nicht.",
+17 -6
View File
@@ -1193,8 +1193,8 @@
"applyOsUpdate": "Apply OS update", "applyOsUpdate": "Apply OS update",
"osUpToDate": "OS up to date", "osUpToDate": "OS up to date",
"installedByHelperPrefix": "Installed by", "installedByHelperPrefix": "Installed by",
"helperScriptsName": "Proxmox Helper-Scripts", "helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "— updates run the community-scripts helper.", "helperUpdatesRun": "Installed by Proxmox Helper-Scripts.",
"installedPrefix": "installed", "installedPrefix": "installed",
"upstreamAvailable": "upstream {version} available", "upstreamAvailable": "upstream {version} available",
"upToDateAt": "Up to date at", "upToDateAt": "Up to date at",
@@ -1263,7 +1263,12 @@
"ociBody": "This container was created from an OCI (Docker) image. Update management for OCI containers is coming with the upcoming OCI install feature — updates will rebuild the container from a newer image tag rather than patching packages inside.", "ociBody": "This container was created from an OCI (Docker) image. Update management for OCI containers is coming with the upcoming OCI install feature — updates will rebuild the container from a newer image tag rather than patching packages inside.",
"helperNotUpdateable": "The community-scripts registry marks this app as not updateable.", "helperNotUpdateable": "The community-scripts registry marks this app as not updateable.",
"helperDetectedTitle": "Detected a helper-scripts updater", "helperDetectedTitle": "Detected a helper-scripts updater",
"helperDetectedBody": "Applying manually is safest." "helperDetectedBody": "Applying manually is safest.",
"postApplyChecking": "Verifying update result…",
"postApplyAllOk": "{count} package(s) applied successfully — nothing pending.",
"postApplyNothingPending": "Nothing pending — everything is up to date.",
"postApplyPartial": "{pending} package(s) still pending after the run.",
"postApplyPartialSubline": "{applied} applied. Some updates did not complete — review the terminal output above."
}, },
"appEditor": { "appEditor": {
"closePanel": "Close panel", "closePanel": "Close panel",
@@ -1427,9 +1432,9 @@
}, },
"interfaceLanguage": { "interfaceLanguage": {
"title": "Interface language", "title": "Interface language",
"description": "Choose the language used by the Monitor dashboard. Missing translations fall back to English.", "description": "Choose the language used by the Monitor dashboard.",
"label": "Dashboard language", "label": "Dashboard language",
"fallbackNote": "Untranslated text is shown in English until the community fills it in.", "fallbackNote": "",
"statusComplete": "complete", "statusComplete": "complete",
"statusPartial": "partial", "statusPartial": "partial",
"statusNeedsTranslation": "community translation needed" "statusNeedsTranslation": "community translation needed"
@@ -2994,7 +2999,13 @@
"dontShowAgain": "Don't show again for this version", "dontShowAgain": "Don't show again for this version",
"currentFeatures": { "currentFeatures": {
"hostUpdate": "One-click host update from the Health Monitor. The new Update Now button in System Updates runs the Proxmox update flow inside a dashboard terminal, without leaving the browser.", "hostUpdate": "One-click host update from the Health Monitor. The new Update Now button in System Updates runs the Proxmox update flow inside a dashboard terminal, without leaving the browser.",
"mobileInstall": "In-app install prompt for mobile. First-time visitors on Android and iOS Safari now see simple steps for adding the Monitor to their home screen as a PWA." "mobileInstall": "In-app install prompt for mobile. First-time visitors on Android and iOS Safari now see simple steps for adding the Monitor to their home screen as a PWA.",
"pageSpeed": "Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
"appTab": "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.",
"updatesTab": "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.",
"backupNoTimeout": "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.",
"vmDiskUsage": "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.",
"pwaInstall": "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."
} }
}, },
"network": { "network": {
+109 -98
View File
@@ -15,7 +15,7 @@
"unknown": "Desconocido" "unknown": "Desconocido"
}, },
"actions": { "actions": {
"refresh": "Refrescar", "refresh": "Recargar",
"toggleTheme": "Alternar tema", "toggleTheme": "Alternar tema",
"openUserMenu": "Abrir menú de usuario", "openUserMenu": "Abrir menú de usuario",
"cancel": "Cancelar", "cancel": "Cancelar",
@@ -29,12 +29,12 @@
"copyToClipboard": "Copiar al portapapeles" "copyToClipboard": "Copiar al portapapeles"
}, },
"navigation": { "navigation": {
"overview": "Descripción general", "overview": "General",
"storage": "Almacenamiento", "storage": "Almacenamiento",
"network": "Red", "network": "Red",
"virtualMachines": "VM y LXC", "virtualMachines": "VM y LXC",
"hardware": "Hardware", "hardware": "Hardware",
"backup": "Respaldo", "backup": "Backup",
"terminal": "Terminal", "terminal": "Terminal",
"systemLogs": "Registros del sistema", "systemLogs": "Registros del sistema",
"security": "Seguridad", "security": "Seguridad",
@@ -46,10 +46,10 @@
"menu": "Menú de navegación" "menu": "Menú de navegación"
}, },
"status": { "status": {
"healthy": "Saludable", "healthy": "OK",
"warning": "Advertencia", "warning": "Advertencia",
"critical": "Crítico", "critical": "Crítico",
"uptime": "Tiempo de actividad: {uptime}", "uptime": "Uptime: {uptime}",
"node": "Nodo: {node}", "node": "Nodo: {node}",
"connectionFailed": "Error de conexión al servidor ProxMenux", "connectionFailed": "Error de conexión al servidor ProxMenux",
"checkService": "Compruebe que monitor.service se esté ejecutando correctamente.", "checkService": "Compruebe que monitor.service se esté ejecutando correctamente.",
@@ -60,7 +60,7 @@
"hot": "Caliente", "hot": "Caliente",
"moderate": "Moderado", "moderate": "Moderado",
"high": "Alto", "high": "Alto",
"stopped": "Interrumpido", "stopped": "Detenido",
"active": "Activo", "active": "Activo",
"disabled": "Desactivado", "disabled": "Desactivado",
"inactive": "inactivo", "inactive": "inactivo",
@@ -96,12 +96,12 @@
"free": "Gratis", "free": "Gratis",
"activeVmLxc": "VM y LXC activos", "activeVmLxc": "VM y LXC activos",
"runningCount": "{count} en ejecución", "runningCount": "{count} en ejecución",
"vmsCount": "{count} VMs", "vmsCount": "{count} VM",
"stoppedCount": "{count} detenidos", "stoppedCount": "{count} detenidos",
"temperature": "Temperatura", "temperature": "Temperatura",
"noSensorAvailable": "No hay sensores disponibles", "noSensorAvailable": "No hay sensores disponibles",
"collectingData": "Recopilando datos...", "collectingData": "Recopilando datos...",
"storageOverview": "Descripción general del almacenamiento", "storageOverview": "Almacenamiento",
"totalNodeCapacity": "Capacidad total del nodo:", "totalNodeCapacity": "Capacidad total del nodo:",
"totalCapacity": "Capacidad total:", "totalCapacity": "Capacidad total:",
"physicalDisks": "Discos físicos:", "physicalDisks": "Discos físicos:",
@@ -114,7 +114,7 @@
"noVmLxcStorage": "No hay almacenamiento VM/LXC configurado", "noVmLxcStorage": "No hay almacenamiento VM/LXC configurado",
"localStorageSystem": "Almacenamiento local (sistema)", "localStorageSystem": "Almacenamiento local (sistema)",
"storageDataUnavailable": "Datos de almacenamiento no disponibles", "storageDataUnavailable": "Datos de almacenamiento no disponibles",
"networkOverview": "Descripción general de la red", "networkOverview": "Red",
"activeInterfaces": "Interfaces activas:", "activeInterfaces": "Interfaces activas:",
"received": "Recibió:", "received": "Recibió:",
"sent": "Enviado:", "sent": "Enviado:",
@@ -122,7 +122,7 @@
"sentShort": "Enviado", "sentShort": "Enviado",
"networkDataUnavailable": "Datos de red no disponibles", "networkDataUnavailable": "Datos de red no disponibles",
"systemInformation": "Información del sistema", "systemInformation": "Información del sistema",
"uptime": "Tiempo de actividad:", "uptime": "Uptime:",
"uptimeDuration": { "uptimeDuration": {
"dayOne": "{count} día", "dayOne": "{count} día",
"dayFew": "{count} días", "dayFew": "{count} días",
@@ -132,7 +132,7 @@
"kernel": "Núcleo:", "kernel": "Núcleo:",
"availableUpdates": "Actualizaciones disponibles:", "availableUpdates": "Actualizaciones disponibles:",
"packages": "paquetes", "packages": "paquetes",
"systemOverview": "Descripción general del sistema", "systemOverview": "Sistema",
"loadAverage1m": "Carga Promedio (1m):", "loadAverage1m": "Carga Promedio (1m):",
"cpuThreads": "Hilos de CPU:", "cpuThreads": "Hilos de CPU:",
"networkInterfaces": "Interfaces de red:", "networkInterfaces": "Interfaces de red:",
@@ -214,10 +214,10 @@
"viewDetails": "Ver detalles", "viewDetails": "Ver detalles",
"diskDetails": "Detalles del disco: /dev/{name}", "diskDetails": "Detalles del disco: /dev/{name}",
"physicalDisk": "Disco físico", "physicalDisk": "Disco físico",
"overview": "Descripción general", "overview": "General",
"smart": "SMART", "smart": "SMART",
"history": "Historia", "history": "Historia",
"schedule": "Cronograma", "schedule": "Programación",
"serialNumber": "Número de serie", "serialNumber": "Número de serie",
"healthStatus": "Estado de salud", "healthStatus": "Estado de salud",
"wearLifetime": "Desgaste y vida útil", "wearLifetime": "Desgaste y vida útil",
@@ -226,7 +226,7 @@
"lifeRemaining": "Vida restante", "lifeRemaining": "Vida restante",
"realTest": "Prueba real", "realTest": "Prueba real",
"wear": { "wear": {
"label": "Tener puesto", "label": "Desgaste",
"percentageUsed": "Porcentaje utilizado", "percentageUsed": "Porcentaje utilizado",
"mediaWearout": "Desgaste de los medios", "mediaWearout": "Desgaste de los medios",
"wearLevel": "Nivel de desgaste", "wearLevel": "Nivel de desgaste",
@@ -270,11 +270,11 @@
"connection_error": "Problema de conexión" "connection_error": "Problema de conexión"
}, },
"health": { "health": {
"healthy": "Saludable", "healthy": "OK",
"warning": "Advertencia", "warning": "Advertencia",
"critical": "Crítico", "critical": "Crítico",
"unknown": "Desconocido", "unknown": "Desconocido",
"passed": "Aprobado", "passed": "Correcto",
"online": "En línea", "online": "En línea",
"failed": "Fallido", "failed": "Fallido",
"degraded": "Degradado" "degraded": "Degradado"
@@ -320,7 +320,7 @@
"loadingReport": "Cargando informe...", "loadingReport": "Cargando informe...",
"reportLoadFailed": "No se pudieron cargar los datos del informe.", "reportLoadFailed": "No se pudieron cargar los datos del informe.",
"statusValues": { "statusValues": {
"passed": "Aprobado", "passed": "Correcto",
"failed": "Fallido", "failed": "Fallido",
"running": "En ejecución", "running": "En ejecución",
"aborted": "Abortado", "aborted": "Abortado",
@@ -353,7 +353,7 @@
"executiveSummary": "Resumen ejecutivo", "executiveSummary": "Resumen ejecutivo",
"smartStatus": "Estado SMART", "smartStatus": "Estado SMART",
"healthAssessment": "Evaluación del estado del disco", "healthAssessment": "Evaluación del estado del disco",
"passedUpper": "APROBADO", "passedUpper": "CORRECTO",
"testAgeWarning": "Este informe se basa en una prueba SMART realizada hace {days} días ({date}). Es posible que la salud del disco haya cambiado desde entonces. Recomendamos ejecutar una nueva prueba SMART para obtener resultados actualizados.", "testAgeWarning": "Este informe se basa en una prueba SMART realizada hace {days} días ({date}). Es posible que la salud del disco haya cambiado desde entonces. Recomendamos ejecutar una nueva prueba SMART para obtener resultados actualizados.",
"healthyAssessment": "Este disco está funcionando dentro de los parámetros normales. Todos los atributos SMART están dentro de umbrales aceptables. El disco ha estado encendido durante aproximadamente {uptime} y actualmente está funcionando a {temperature}. {sectors}", "healthyAssessment": "Este disco está funcionando dentro de los parámetros normales. Todos los atributos SMART están dentro de umbrales aceptables. El disco ha estado encendido durante aproximadamente {uptime} y actualmente está funcionando a {temperature}. {sectors}",
"failedAssessment": "Este disco ha informado de un error de salud SMART. Se requiere acción inmediata. Haga una copia de seguridad de todos los datos críticos y planifique el reemplazo del disco.", "failedAssessment": "Este disco ha informado de un error de salud SMART. Se requiere acción inmediata. Haga una copia de seguridad de todos los datos críticos y planifique el reemplazo del disco.",
@@ -461,11 +461,11 @@
"warning": "Advertencia", "warning": "Advertencia",
"critical": "Crítico", "critical": "Crítico",
"failed": "Fallido", "failed": "Fallido",
"passed": "Aprobado", "passed": "Correcto",
"supported": "Apoyado", "supported": "Apoyado",
"notSupported": "No compatible", "notSupported": "No compatible",
"none": "Ninguno", "none": "Ninguno",
"info": "Información" "info": "Info"
}, },
"selfTestStatus": { "selfTestStatus": {
"completedWithoutError": "Completado sin errores", "completedWithoutError": "Completado sin errores",
@@ -884,7 +884,7 @@
"sleeping": "Durmiendo", "sleeping": "Durmiendo",
"diskWait": "espera de disco", "diskWait": "espera de disco",
"zombie": "Zombi", "zombie": "Zombi",
"stopped": "Interrumpido", "stopped": "Detenido",
"tracingStop": "parada de rastreo", "tracingStop": "parada de rastreo",
"dead": "Muerto", "dead": "Muerto",
"idle": "Inactivo", "idle": "Inactivo",
@@ -900,8 +900,8 @@
"totalCpuAllocated": "CPU total asignada", "totalCpuAllocated": "CPU total asignada",
"totalMemory": "Memoria Total", "totalMemory": "Memoria Total",
"totalDisk": "Disco total", "totalDisk": "Disco total",
"running": "correr", "running": "Activo",
"stopped": "interrumpido", "stopped": "Detenido",
"vms": "máquinas virtuales", "vms": "máquinas virtuales",
"used": "Usado", "used": "Usado",
"configured": "Configurado", "configured": "Configurado",
@@ -912,7 +912,7 @@
"idle": "Inactivo", "idle": "Inactivo",
"listTitle": "Máquinas virtuales y contenedores", "listTitle": "Máquinas virtuales y contenedores",
"empty": "No se encontraron máquinas virtuales", "empty": "No se encontraron máquinas virtuales",
"uptime": "Tiempo de actividad: {uptime}", "uptime": "Uptime: {uptime}",
"cpuUsage": "Uso de CPU", "cpuUsage": "Uso de CPU",
"memory": "Memoria", "memory": "Memoria",
"disk": "Disco", "disk": "Disco",
@@ -925,8 +925,8 @@
"cpuCores": "Núcleos de CPU", "cpuCores": "Núcleos de CPU",
"notes": "Notas", "notes": "Notas",
"hideNotes": "Ocultar notas", "hideNotes": "Ocultar notas",
"lessInfo": "Menos información", "lessInfo": "Menos info",
"info": "Información", "info": "Info",
"ipAddresses": "Direcciones IP", "ipAddresses": "Direcciones IP",
"enterNotes": "Introduzca notas aquí...", "enterNotes": "Introduzca notas aquí...",
"editNotes": "Editar", "editNotes": "Editar",
@@ -998,7 +998,7 @@
"type": "Tipo", "type": "Tipo",
"rateLimit": "Límite de tarifa", "rateLimit": "Límite de tarifa",
"firewall": "Cortafuegos", "firewall": "Cortafuegos",
"backup": "Respaldo", "backup": "Backup",
"replicate": "Reproducir exactamente", "replicate": "Reproducir exactamente",
"volume": "Volumen", "volume": "Volumen",
"path": "Ruta", "path": "Ruta",
@@ -1019,7 +1019,7 @@
"notMounted": "no montado", "notMounted": "no montado",
"hostDetached": "anfitrión independiente", "hostDetached": "anfitrión independiente",
"readOnly": "solo lectura", "readOnly": "solo lectura",
"stopped": "interrumpido", "stopped": "Detenido",
"mounted": "montado" "mounted": "montado"
} }
}, },
@@ -1107,7 +1107,7 @@
"notes": "Notas", "notes": "Notas",
"variables": "Variables: {{cluster}}, {{guestname}}, {{node}}, {{vmid}}", "variables": "Variables: {{cluster}}, {{guestname}}, {{node}}, {{vmid}}",
"creating": "Creando...", "creating": "Creando...",
"submit": "Respaldo", "submit": "Backup",
"typeContainer": "recipiente", "typeContainer": "recipiente",
"typeVirtualMachine": "máquina virtual", "typeVirtualMachine": "máquina virtual",
"modes": { "modes": {
@@ -1123,7 +1123,7 @@
}, },
"firewall": { "firewall": {
"title": "Registros de cortafuegos", "title": "Registros de cortafuegos",
"refresh": "Refrescar", "refresh": "Recargar",
"loading": "Cargando registro del cortafuegos...", "loading": "Cargando registro del cortafuegos...",
"disabledTitle": "El cortafuegos no está habilitado para este {type}", "disabledTitle": "El cortafuegos no está habilitado para este {type}",
"disabledHint": "Habilítelo en la interfaz de usuario de Proxmox en {type} -> Firewall -> Opciones y agregue al menos una regla con registro: información o superior para que los paquetes comiencen a registrarse. Las nuevas entradas aparecerán aquí automáticamente en la próxima actualización.", "disabledHint": "Habilítelo en la interfaz de usuario de Proxmox en {type} -> Firewall -> Opciones y agregue al menos una regla con registro: información o superior para que los paquetes comiencen a registrarse. Las nuevas entradas aparecerán aquí automáticamente en la próxima actualización.",
@@ -1151,7 +1151,7 @@
"title": "Actualizaciones programadas", "title": "Actualizaciones programadas",
"enabledHelper": "Se ejecuta en segundo plano usando las opciones de aplicación anteriores.", "enabledHelper": "Se ejecuta en segundo plano usando las opciones de aplicación anteriores.",
"disabledHelper": "Habilite la ejecución de actualizaciones automáticamente según una programación cron.", "disabledHelper": "Habilite la ejecución de actualizaciones automáticamente según una programación cron.",
"chipLabel": "Cronograma", "chipLabel": "Programado",
"disabledSuffix": "(desactivado)", "disabledSuffix": "(desactivado)",
"whatLabel": "Qué:", "whatLabel": "Qué:",
"targetOs": "Paquetes de sistema operativo", "targetOs": "Paquetes de sistema operativo",
@@ -1194,8 +1194,8 @@
"applyOsUpdate": "Aplicar actualizaciones de SO", "applyOsUpdate": "Aplicar actualizaciones de SO",
"osUpToDate": "SO actualizado", "osUpToDate": "SO actualizado",
"installedByHelperPrefix": "Instalado por", "installedByHelperPrefix": "Instalado por",
"helperScriptsName": "Proxmox Helper-Scripts", "helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "— las actualizaciones ejecutan el asistente de scripts comunitarios.", "helperUpdatesRun": "Instalado por Proxmox Helper-Scripts.",
"installedPrefix": "instalado", "installedPrefix": "instalado",
"upstreamAvailable": "versión {version} disponible", "upstreamAvailable": "versión {version} disponible",
"upToDateAt": "Actualizado en", "upToDateAt": "Actualizado en",
@@ -1264,7 +1264,12 @@
"ociBody": "Este contenedor se creó a partir de una imagen OCI (Docker). La gestión de actualizaciones para contenedores OCI viene con la próxima función de instalación de OCI: las actualizaciones reconstruirán el contenedor a partir de una etiqueta de imagen más nueva en lugar de aplicar parches a los paquetes internos.", "ociBody": "Este contenedor se creó a partir de una imagen OCI (Docker). La gestión de actualizaciones para contenedores OCI viene con la próxima función de instalación de OCI: las actualizaciones reconstruirán el contenedor a partir de una etiqueta de imagen más nueva en lugar de aplicar parches a los paquetes internos.",
"helperNotUpdateable": "El registro de scripts comunitarios marca esta aplicación como no actualizable.", "helperNotUpdateable": "El registro de scripts comunitarios marca esta aplicación como no actualizable.",
"helperDetectedTitle": "Detectado un actualizador de scripts auxiliares", "helperDetectedTitle": "Detectado un actualizador de scripts auxiliares",
"helperDetectedBody": "La aplicación manual es la más segura." "helperDetectedBody": "La aplicación manual es la más segura.",
"postApplyChecking": "Comprobando resultado de la actualización…",
"postApplyAllOk": "{count} paquete(s) aplicados correctamente — nada pendiente.",
"postApplyNothingPending": "Nada pendiente — todo actualizado.",
"postApplyPartial": "{pending} paquete(s) siguen pendientes tras la ejecución.",
"postApplyPartialSubline": "{applied} aplicados. Algunas actualizaciones no finalizaron — revisa la salida del terminal."
}, },
"appEditor": { "appEditor": {
"closePanel": "Cerrar panel", "closePanel": "Cerrar panel",
@@ -1428,9 +1433,9 @@
}, },
"interfaceLanguage": { "interfaceLanguage": {
"title": "Idioma de la interfaz", "title": "Idioma de la interfaz",
"description": "Elija el idioma utilizado por el panel de Monitor. Las traducciones que faltan vuelven al inglés.", "description": "Elija el idioma utilizado por el panel de Monitor.",
"label": "Idioma del panel", "label": "Idioma del panel",
"fallbackNote": "El texto no traducido se muestra en inglés hasta que la comunidad lo completa.", "fallbackNote": "",
"statusComplete": "completo", "statusComplete": "completo",
"statusPartial": "parcial", "statusPartial": "parcial",
"statusNeedsTranslation": "Se necesita traducción comunitaria." "statusNeedsTranslation": "Se necesita traducción comunitaria."
@@ -1509,7 +1514,7 @@
"description": "Excluya las interfaces de red (puentes, enlaces, NIC físicas) del monitoreo y las notificaciones de estado. Úselo para interfaces que están deshabilitadas o no utilizadas intencionalmente.", "description": "Excluya las interfaces de red (puentes, enlaces, NIC físicas) del monitoreo y las notificaciones de estado. Úselo para interfaces que están deshabilitadas o no utilizadas intencionalmente.",
"emptyTitle": "No se detectaron interfaces de red", "emptyTitle": "No se detectaron interfaces de red",
"interface": "Interfaz", "interface": "Interfaz",
"down": "ABAJO", "down": "DOWN",
"excluded": "excluido", "excluded": "excluido",
"noIp": "Sin IP", "noIp": "Sin IP",
"healthHelp": "Cuando está APAGADA, esta interfaz no activará advertencias ni alertas críticas en Health Monitor.", "healthHelp": "Cuando está APAGADA, esta interfaz no activará advertencias ni alertas críticas en Health Monitor.",
@@ -2234,7 +2239,7 @@
"accept": "Aceptar", "accept": "Aceptar",
"blockReject": "Bloquear/rechazar", "blockReject": "Bloquear/rechazar",
"portsCovered": "Puertos cubiertos", "portsCovered": "Puertos cubiertos",
"drop": "Gota", "drop": "Descartar",
"reject": "Rechazar", "reject": "Rechazar",
"cluster": "Grupo", "cluster": "Grupo",
"host": "Anfitrión", "host": "Anfitrión",
@@ -2673,7 +2678,7 @@
}, },
"status": { "status": {
"connected": "Conectado", "connected": "Conectado",
"stopped": "Interrumpido", "stopped": "Detenido",
"error": "Error" "error": "Error"
}, },
"installed": { "installed": {
@@ -2877,7 +2882,7 @@
"emergency": "Emergencia", "emergency": "Emergencia",
"alert": "Alerta", "alert": "Alerta",
"warning": "Advertencia", "warning": "Advertencia",
"info": "Información", "info": "Info",
"notice": "Aviso", "notice": "Aviso",
"success": "Éxito", "success": "Éxito",
"debug": "Depurar" "debug": "Depurar"
@@ -2995,7 +3000,13 @@
"dontShowAgain": "No volver a mostrar para esta versión", "dontShowAgain": "No volver a mostrar para esta versión",
"currentFeatures": { "currentFeatures": {
"hostUpdate": "Actualización del host con un solo clic desde Health Monitor. El nuevo botón Actualizar ahora en Actualizaciones del sistema ejecuta el flujo de actualización de Proxmox dentro de una terminal del tablero, sin salir del navegador.", "hostUpdate": "Actualización del host con un solo clic desde Health Monitor. El nuevo botón Actualizar ahora en Actualizaciones del sistema ejecuta el flujo de actualización de Proxmox dentro de una terminal del tablero, sin salir del navegador.",
"mobileInstall": "Aviso de instalación en la aplicación para dispositivos móviles. Quienes visitan Safari por primera vez en Android e iOS ahora ven pasos sencillos para agregar el monitor a su pantalla de inicio como PWA." "mobileInstall": "Aviso de instalación en la aplicación para dispositivos móviles. Quienes visitan Safari por primera vez en Android e iOS ahora ven pasos sencillos para agregar el monitor a su pantalla de inicio como PWA.",
"pageSpeed": "Carga de páginas más rápida y navegación más fluida en todo el panel. La página de Inicio abre al instante y la página de VMs y LXC ya no muestra 'Cargando…' al abrir los modales de cada máquina.",
"appTab": "Nueva pestaña App dentro del modal de VM y LXC — especialmente para los LXC. Registra las aplicaciones instaladas en un contenedor, guarda sus enlaces web y recibe notificaciones cuando aparece una nueva versión.",
"updatesTab": "Pestaña Updates rediseñada para LXC: aplica las actualizaciones del SO y de las apps registradas desde un mismo botón, y programa una tarea de auto-actualización recurrente que revisa el SO del contenedor y la app registrada en cada ejecución.",
"backupNoTimeout": "Las copias de seguridad largas ya no expiran. Los backups de VM y LXC lanzados desde el Monitor se ejecutan en segundo plano hasta terminar de forma natural, así que un backup de 30 min a PBS se completa igual que uno local de 10 s.",
"vmDiskUsage": "Las VM con el QEMU Guest Agent (qemu-guest-agent) instalado muestran ahora el uso real de disco (usado / total) en el panel, en lugar del '0 GB' que devuelve PVE para los sistemas de archivos gestionados por el huésped.",
"pwaInstall": "Los usuarios que abren el Monitor por primera vez en Android y iOS Safari ven ahora un aviso dentro de la app con los pasos para añadir el Monitor a la pantalla de inicio como PWA."
} }
}, },
"network": { "network": {
@@ -3026,7 +3037,7 @@
"activeCount": "{active}/{total} Activo" "activeCount": "{active}/{total} Activo"
}, },
"status": { "status": {
"healthy": "Saludable", "healthy": "OK",
"warning": "Advertencia", "warning": "Advertencia",
"critical": "Crítico", "critical": "Crítico",
"up": "Arriba", "up": "Arriba",
@@ -3058,7 +3069,7 @@
"current": "Actual", "current": "Actual",
"domain": "Dominio", "domain": "Dominio",
"down": "Abajo", "down": "Abajo",
"drops": "Gotas", "drops": "DESCARTADOS",
"dropsIn": "Gotas en", "dropsIn": "Gotas en",
"dropsOut": "Se retira", "dropsOut": "Se retira",
"duplex": "Dúplex", "duplex": "Dúplex",
@@ -3362,8 +3373,8 @@
"reinstallViaPostInstall": "Reinstale a través de ProxMenux después de la instalación: {label}", "reinstallViaPostInstall": "Reinstale a través de ProxMenux después de la instalación: {label}",
"noUpdatesAvailable": "No hay actualizaciones disponibles", "noUpdatesAvailable": "No hay actualizaciones disponibles",
"none": "ninguno", "none": "ninguno",
"running": "correr", "running": "Activo",
"stopped": "interrumpido", "stopped": "Detenido",
"unused": "no usado", "unused": "no usado",
"unbound": "sin consolidar", "unbound": "sin consolidar",
"driversReady": "Controladores listos", "driversReady": "Controladores listos",
@@ -3520,8 +3531,8 @@
"archives": { "archives": {
"archive": "Archivo", "archive": "Archivo",
"at": "en", "at": "en",
"backup": "Respaldo", "backup": "Backup",
"backupTimeLabel": "tiempo de respaldo", "backupTimeLabel": "Creado",
"deleteBackendTitle": "Eliminar esta copia de seguridad", "deleteBackendTitle": "Eliminar esta copia de seguridad",
"deleteBorgDescription": "Esto elimina el archivo Borg del repositorio.", "deleteBorgDescription": "Esto elimina el archivo Borg del repositorio.",
"deleteBorgTitle": "Eliminar archivo Borg", "deleteBorgTitle": "Eliminar archivo Borg",
@@ -3543,7 +3554,7 @@
"importKeyToRestoreTitle": "Importe el archivo de claves correspondiente antes de restaurar", "importKeyToRestoreTitle": "Importe el archivo de claves correspondiente antes de restaurar",
"importKeyToViewTitle": "Importe el archivo de claves correspondiente antes de ver el contenido", "importKeyToViewTitle": "Importe el archivo de claves correspondiente antes de ver el contenido",
"inspectTitle": "Inspeccionar copia de seguridad", "inspectTitle": "Inspeccionar copia de seguridad",
"job": "Trabajo", "job": "Tarea",
"loadFailed": "No se pudieron cargar las copias de seguridad", "loadFailed": "No se pudieron cargar las copias de seguridad",
"packedSizeLabel": "Tamaño empaquetado", "packedSizeLabel": "Tamaño empaquetado",
"remoteQueryWarning": "Las copias de seguridad remotas pueden tardar un momento en consultarse.", "remoteQueryWarning": "Las copias de seguridad remotas pueden tardar un momento en consultarse.",
@@ -3554,7 +3565,7 @@
}, },
"backends": { "backends": {
"borgDescription": "Repositorio Borg para copias de seguridad de host deduplicadas.", "borgDescription": "Repositorio Borg para copias de seguridad de host deduplicadas.",
"borgDescriptionTimerOnly": "Los destinos Borg son utilizados por los temporizadores Monitor, no por los trabajos PVE vzdump.", "borgDescriptionTimerOnly": "Los destinos Borg son utilizados por los temporizadores Monitor, no por los tareas PVE vzdump.",
"borgShortDescription": "Repositorio Borg deduplicado", "borgShortDescription": "Repositorio Borg deduplicado",
"local": "Local", "local": "Local",
"localDescription": "Carpeta en este host o en una unidad USB montada.", "localDescription": "Carpeta en este host o en una unidad USB montada.",
@@ -3596,10 +3607,10 @@
}, },
"deleteJob": { "deleteJob": {
"attachedToStorage": "Adjunto al almacenamiento PVE", "attachedToStorage": "Adjunto al almacenamiento PVE",
"attachedWarning": "Este trabajo está conectado a un gancho de almacenamiento PVE. Al eliminarlo, también se elimina el gancho del Monitor de ese almacenamiento.", "attachedWarning": "Esta tarea está conectada a un gancho de almacenamiento PVE. Al eliminarla, también se elimina el gancho del Monitor de ese almacenamiento.",
"description": "Se eliminarán los metadatos del temporizador y del monitor para este trabajo.", "description": "Se eliminarán los metadatos del temporizador y del monitor para esta tarea.",
"timerWarning": "Se eliminará el temporizador systemd. Las copias de seguridad ya creadas permanecen en su destino.", "timerWarning": "Se eliminará el temporizador systemd. Las copias de seguridad ya creadas permanecen en su destino.",
"title": "Eliminar trabajo de copia de seguridad" "title": "Eliminar tarea de copia de seguridad"
}, },
"destinations": { "destinations": {
"absolutePathHelp": "Utilice una ruta absoluta en este host.", "absolutePathHelp": "Utilice una ruta absoluta en este host.",
@@ -3624,7 +3635,7 @@
"editTitle": "Editar destino", "editTitle": "Editar destino",
"fingerprintHelp": "Verificación opcional de la huella digital del servidor.", "fingerprintHelp": "Verificación opcional de la huella digital del servidor.",
"generateNewSshKey": "Generar nueva clave SSH", "generateNewSshKey": "Generar nueva clave SSH",
"jobsWillBeDeleted": "También se eliminarán {count} trabajos que utilicen este destino.", "jobsWillBeDeleted": "También se eliminarán {count} tareas que utilicen este destino.",
"kept": "conservó", "kept": "conservó",
"leavePassphraseBlank": "Déjelo en blanco para conservar la frase de contraseña guardada.", "leavePassphraseBlank": "Déjelo en blanco para conservar la frase de contraseña guardada.",
"leavePasswordBlank": "Déjelo en blanco para conservar la contraseña guardada.", "leavePasswordBlank": "Déjelo en blanco para conservar la contraseña guardada.",
@@ -3638,10 +3649,10 @@
"localUsbDescription": "Carpeta en este host o una unidad USB montada.", "localUsbDescription": "Carpeta en este host o una unidad USB montada.",
"managedByPve": "Gestionado por PVE", "managedByPve": "Gestionado por PVE",
"manuallyAdded": "Agregado manualmente", "manuallyAdded": "Agregado manualmente",
"nameCredentialsLocked": "El nombre y las credenciales están bloqueados mientras los trabajos utilizan este destino.", "nameCredentialsLocked": "El nombre y las credenciales están bloqueados mientras los tareas utilizan este destino.",
"namePassphraseLocked": "El nombre del repositorio y la frase de contraseña están bloqueados mientras los trabajos utilizan este destino.", "namePassphraseLocked": "El nombre del repositorio y la frase de contraseña están bloqueados mientras los tareas utilizan este destino.",
"noBorgInDialog": "La edición de destinos de Borg está disponible desde el flujo de configuración de Borg.", "noBorgInDialog": "La edición de destinos de Borg está disponible desde el flujo de configuración de Borg.",
"noBorgInWizard": "Borg no está disponible para trabajos adjuntos PVE.", "noBorgInWizard": "Borg no está disponible para tareas adjuntos PVE.",
"noPbsInDialog": "La edición de destino de PBS está disponible desde el flujo de configuración de PBS.", "noPbsInDialog": "La edición de destino de PBS está disponible desde el flujo de configuración de PBS.",
"noPbsInWizard": "Aún no se han configurado repositorios de PBS.", "noPbsInWizard": "Aún no se han configurado repositorios de PBS.",
"not": "no", "not": "no",
@@ -3649,7 +3660,7 @@
"probingCapacity": "Comprobando capacidad...", "probingCapacity": "Comprobando capacidad...",
"remoteSsh": "SSH remoto", "remoteSsh": "SSH remoto",
"remoteSshDescription": "Repositorio Borg en otro host a través de SSH.", "remoteSshDescription": "Repositorio Borg en otro host a través de SSH.",
"removeAndDeleteJobs": "Eliminar destino y eliminar trabajos", "removeAndDeleteJobs": "Eliminar destino y eliminar tareas",
"removeDescriptionPlain": "Esto solo elimina el destino del Monitor. Las copias de seguridad existentes permanecen donde están.", "removeDescriptionPlain": "Esto solo elimina el destino del Monitor. Las copias de seguridad existentes permanecen donde están.",
"removeDescriptionWithData": "Esto elimina el destino del Monitor. Las copias de seguridad existentes permanecen donde están.", "removeDescriptionWithData": "Esto elimina el destino del Monitor. Las copias de seguridad existentes permanecen donde están.",
"removeDestination": "Eliminar destino", "removeDestination": "Eliminar destino",
@@ -3672,7 +3683,7 @@
"autoDetectedFromPveStorage": "Detectado desde el almacenamiento PVE", "autoDetectedFromPveStorage": "Detectado desde el almacenamiento PVE",
"borgPassphraseLossBefore": "Si pierde la frase de contraseña de Borg, las copias de seguridad cifradas no se podrán restaurar.", "borgPassphraseLossBefore": "Si pierde la frase de contraseña de Borg, las copias de seguridad cifradas no se podrán restaurar.",
"borgPassphraseOnlyWay": "Esta frase de contraseña es la única forma de desbloquear el repositorio.", "borgPassphraseOnlyWay": "Esta frase de contraseña es la única forma de desbloquear el repositorio.",
"borgPassphraseSavedAfter": "para que los trabajos programados puedan ejecutarse sin supervisión.", "borgPassphraseSavedAfter": "para que los tareas programados puedan ejecutarse sin supervisión.",
"borgPassphraseSavedAt": "La frase de contraseña se guarda en", "borgPassphraseSavedAt": "La frase de contraseña se guarda en",
"encryptBackups": "Cifrar copias de seguridad", "encryptBackups": "Cifrar copias de seguridad",
"encryptThisBackup": "Cifre esta copia de seguridad", "encryptThisBackup": "Cifre esta copia de seguridad",
@@ -3720,7 +3731,7 @@
"repoNameAndSnapshotRequired": "Se requieren el nombre del repositorio y la instantánea.", "repoNameAndSnapshotRequired": "Se requieren el nombre del repositorio y la instantánea.",
"runFailed": "Error de ejecución: {error}", "runFailed": "Error de ejecución: {error}",
"snapshotInfoMissing": "Falta información de la instantánea.", "snapshotInfoMissing": "Falta información de la instantánea.",
"toggleFailed": "No se pudo cambiar el estado del trabajo: {error}" "toggleFailed": "No se pudo cambiar el estado de la tarea: {error}"
}, },
"extraPaths": { "extraPaths": {
"absolutePathHintAfter": "— la ruta debe existir en este host exactamente como se escribió.", "absolutePathHintAfter": "— la ruta debe existir en este host exactamente como se escribió.",
@@ -3759,9 +3770,9 @@
"fingerprintOptional": "Huella digital (opcional)", "fingerprintOptional": "Huella digital (opcional)",
"free": "Gratis", "free": "Gratis",
"groupNameInPbs": "Nombre del grupo en PBS", "groupNameInPbs": "Nombre del grupo en PBS",
"jobId": "ID de trabajo", "jobId": "ID de tarea",
"jobIdLabel": "ID de trabajo", "jobIdLabel": "ID de tarea",
"jobName": "Nombre del trabajo", "jobName": "Nombre de la tarea",
"kernel": "Núcleo", "kernel": "Núcleo",
"label": "Etiqueta", "label": "Etiqueta",
"localDestination": "Destino local", "localDestination": "Destino local",
@@ -3781,7 +3792,7 @@
"pathLabel": "Ruta", "pathLabel": "Ruta",
"pbsRepository": "repositorio de PBS", "pbsRepository": "repositorio de PBS",
"profileLabel": "Perfil", "profileLabel": "Perfil",
"pveJobLabel": "trabajo PvE", "pveJobLabel": "tarea PvE",
"pveStorage": "almacenamiento PVE", "pveStorage": "almacenamiento PVE",
"pveVersion": "versión PvE", "pveVersion": "versión PvE",
"recoveryPassphrase": "Frase de contraseña de recuperación", "recoveryPassphrase": "Frase de contraseña de recuperación",
@@ -3793,8 +3804,8 @@
"repositoryPath": "Ruta del repositorio", "repositoryPath": "Ruta del repositorio",
"retentionLabel": "Retención", "retentionLabel": "Retención",
"roles": "Roles", "roles": "Roles",
"schedule": "Cronograma", "schedule": "Programación",
"scheduleLabel": "Cronograma", "scheduleLabel": "Programación",
"serverHostOrIp": "IP/host del servidor", "serverHostOrIp": "IP/host del servidor",
"size": "Tamaño", "size": "Tamaño",
"sizeLabel": "Tamaño", "sizeLabel": "Tamaño",
@@ -3818,56 +3829,56 @@
}, },
"jobs": { "jobs": {
"attachDescriptionAfter": "copias de seguridad.", "attachDescriptionAfter": "copias de seguridad.",
"attachDescriptionBefore": "Adjunte Monitor a un trabajo PVE vzdump y ejecútelo después del", "attachDescriptionBefore": "Une Monitor a una tarea PVE vzdump y ejecútela después del",
"attachDescriptionMiddle": "evento para seleccionados", "attachDescriptionMiddle": "evento para seleccionados",
"attachToPveJob": "Adjuntar al trabajo PVE", "attachToPveJob": "Unirse a una tarea de Backup en PVE",
"attachedToPveVzdump": "Adjunto a PVE vzdump", "attachedToPveVzdump": "Adjunto a PVE vzdump",
"backendEditHelp": "El backend no se puede cambiar una vez creado el trabajo.", "backendEditHelp": "El backend no se puede cambiar una vez creado la tarea.",
"createJob": "crear trabajo", "createJob": "crear tarea",
"createScheduledJob": "Crear trabajo programado", "createScheduledJob": "Crear tarea programado",
"customProfileTitle": "Perfil personalizado", "customProfileTitle": "Perfil personalizado",
"defaultProfileTitle": "Perfil predeterminado", "defaultProfileTitle": "Perfil predeterminado",
"detailDescription": "Programación, perfil, destino y última ejecución de este trabajo.", "detailDescription": "Programación, perfil, destino y última ejecución de esta tarea.",
"disableJob": "Desactivar trabajo", "disableJob": "Desactivar tarea",
"disableJobDescription": "El temporizador systemd se detendrá; no se ejecutarán más automáticamente. Puede volver a habilitarlo más tarde desde este mismo cuadro de diálogo.", "disableJobDescription": "El temporizador systemd se detendrá; no se ejecutarán más automáticamente. Puede volver a habilitarlo más tarde desde este mismo cuadro de diálogo.",
"editScheduledJob": "Editar trabajo programado", "editScheduledJob": "Editar tarea programado",
"encryptedTitle": "cifrado", "encryptedTitle": "cifrado",
"inheritedRetentionLabel": "Retención del trabajo PVE", "inheritedRetentionLabel": "Retención de la tarea PVE",
"inheritedScheduleLabel": "Programación del trabajo PVE", "inheritedScheduleLabel": "Programación de la tarea PVE",
"invalidJobName": "Utilice únicamente letras, números, guiones y guiones bajos.", "invalidJobName": "Utilice únicamente letras, números, guiones y guiones bajos.",
"jobNameHelpAfter": "Sea breve y legible.", "jobNameHelpAfter": "Sea breve y legible.",
"jobNameHelpAnd": "y", "jobNameHelpAnd": "y",
"jobNameHelpBefore": "Se utiliza en nombres y registros de temporizadores. Los caracteres permitidos también incluyen", "jobNameHelpBefore": "Se utiliza en nombres y registros de temporizadores. Los caracteres permitidos también incluyen",
"jobNameLocked": "El nombre del trabajo se bloquea después de la creación.", "jobNameLocked": "El nombre de la tarea se bloquea después de la creación.",
"lastRun": "última ejecución", "lastRun": "última ejecución",
"lastRunLabel": "última ejecución", "lastRunLabel": "última ejecución",
"liveLogSize": "en vivo · {size}", "liveLogSize": "en vivo · {size}",
"loadFailed": "No se pudieron cargar trabajos", "loadFailed": "No se pudieron cargar tareas",
"loadingJob": "Cargando trabajo...", "loadingJob": "Cargando tarea...",
"manualOneShot": "manual / de una sola vez", "manualOneShot": "manual / de una sola vez",
"manualOneShotDescription": "Copia de seguridad de un solo disparo: capturada en el momento del disparo. No se puede volver a ejecutar ni editar.", "manualOneShotDescription": "Copia de seguridad de un solo disparo: capturada en el momento del disparo. No se puede volver a ejecutar ni editar.",
"neverRun": "nunca corras", "neverRun": "nunca corras",
"newScheduledJob": "Nuevo trabajo programado", "newScheduledJob": "Nueva tarea programada",
"newScheduledJobDescription": "Cree una tarea de copia de seguridad del host recurrente.", "newScheduledJobDescription": "Cree una tarea de copia de seguridad del host recurrente.",
"nextRun": "Próxima ejecución", "nextRun": "Próxima ejecución",
"nextRunLabel": "Próxima ejecución", "nextRunLabel": "Próxima ejecución",
"nextRunTitle": "Próxima ejecución programada", "nextRunTitle": "Próxima ejecución programada",
"noCompatiblePveJob": "No se encontró ningún trabajo de copia de seguridad PVE compatible", "noCompatiblePveJob": "No se encontró ningún tarea de copia de seguridad PVE compatible",
"noCompatiblePveJobDescriptionAfter": "primero.", "noCompatiblePveJobDescriptionAfter": "primero.",
"noCompatiblePveJobDescriptionBefore": "Cree o habilite un trabajo de copia de seguridad PVE para", "noCompatiblePveJobDescriptionBefore": "Cree o habilite una tarea de copia de seguridad PVE para",
"noCompatiblePveJobDescriptionMiddle": "en", "noCompatiblePveJobDescriptionMiddle": "en",
"notAvailableForBorg": "no disponible para Borg", "notAvailableForBorg": "no disponible para Borg",
"openJobTitle": "Abrir detalles del trabajo", "openJobTitle": "Abrir detalles de la tarea",
"parentPveJobHelpAfter": "se ejecuta con éxito.", "parentPveJobHelpAfter": "se ejecuta con éxito.",
"parentPveJobHelpBefore": "El monitor se ejecuta después de este trabajo PVE.", "parentPveJobHelpBefore": "El monitor se ejecuta después de esta tarea PVE.",
"pbsGroupHelp": "Este se convierte en el grupo de respaldo de PBS.", "pbsGroupHelp": "Este se convierte en el grupo de respaldo de PBS.",
"pickParentPveJob": "Elige el trabajo PVE principal", "pickParentPveJob": "Elige la tarea PVE principal",
"retentionTitle": "Retención", "retentionTitle": "Retención",
"runAttachedTitle": "Activar una ejecución ad hoc ahora (el temporizador PVE mantiene su propio cronograma)", "runAttachedTitle": "Activar una ejecución ad hoc ahora (el temporizador PVE mantiene su propio cronograma)",
"runNowTitle": "Activa este trabajo ahora", "runNowTitle": "Activa esta tarea ahora",
"running": "Ejecutando...", "running": "Ejecutando...",
"scheduledTitle": "Programado", "scheduledTitle": "Programado",
"standaloneScheduledJob": "Trabajo programado independiente", "standaloneScheduledJob": "Tarea programado independiente",
"starting": "Iniciando...", "starting": "Iniciando...",
"stepOf": "Paso {step} de {total}", "stepOf": "Paso {step} de {total}",
"summary": "Resumen", "summary": "Resumen",
@@ -3949,7 +3960,7 @@
}, },
"logs": { "logs": {
"openFull": "Abrir registro completo", "openFull": "Abrir registro completo",
"runLog": "Ejecutar registro", "runLog": "Registro",
"tail": "cola" "tail": "cola"
}, },
"manifest": { "manifest": {
@@ -3975,7 +3986,7 @@
"manual": { "manual": {
"description": "Ejecute una copia de seguridad única del host ahora.", "description": "Ejecute una copia de seguridad única del host ahora.",
"inProgress": "Copia de seguridad manual en progreso", "inProgress": "Copia de seguridad manual en progreso",
"oneShotDescription": "Las copias de seguridad únicas se mantienen como trabajos congelados para que puedas inspeccionar su registro más tarde.", "oneShotDescription": "Las copias de seguridad únicas se mantienen como tareas congelados para que puedas inspeccionar su registro más tarde.",
"reopenLogTitle": "Reabrir registro", "reopenLogTitle": "Reabrir registro",
"run": "Ejecutar", "run": "Ejecutar",
"runBackup": "Ejecutar copia de seguridad", "runBackup": "Ejecutar copia de seguridad",
@@ -4120,7 +4131,7 @@
"advancedHelpBefore": "Usar", "advancedHelpBefore": "Usar",
"advancedHelpMiddle": "y", "advancedHelpMiddle": "y",
"advancedOption": "Avanzado", "advancedOption": "Avanzado",
"borgTimerOnly": "Los trabajos Borg están programados mediante temporizadores Monitor.", "borgTimerOnly": "Los tareas Borg están programados mediante temporizadores Monitor.",
"dailyOption": "A diario", "dailyOption": "A diario",
"everyDayAt": "Todos los días a las {time}", "everyDayAt": "Todos los días a las {time}",
"everyDayAtMidnight": "Todos los días a medianoche", "everyDayAtMidnight": "Todos los días a medianoche",
@@ -4142,7 +4153,7 @@
"pickFrequency": "Frecuencia de selección", "pickFrequency": "Frecuencia de selección",
"preview": "Avance", "preview": "Avance",
"rawOnCalendar": "{value} (sistema en Calendario)", "rawOnCalendar": "{value} (sistema en Calendario)",
"title": "Cronograma", "title": "Programación",
"weekdaysAt": "{days} en {time}", "weekdaysAt": "{days} en {time}",
"weeklyOption": "Semanalmente" "weeklyOption": "Semanalmente"
}, },
@@ -4153,7 +4164,7 @@
"manual": "manual", "manual": "manual",
"manualOneShot": "manual / de una sola vez", "manualOneShot": "manual / de una sola vez",
"ok": "OK", "ok": "OK",
"running": "correr", "running": "Activo",
"scheduled": "programado" "scheduled": "programado"
}, },
"taskStates": { "taskStates": {
@@ -4162,7 +4173,7 @@
"packing": "embalaje", "packing": "embalaje",
"queued": "en cola", "queued": "en cola",
"restoring": "restaurando", "restoring": "restaurando",
"running": "correr" "running": "Activo"
}, },
"usb": { "usb": {
"description": "Monte unidades USB para que puedan ser elegidas como objetivo local o Borg. Las unidades que ya tenían un sistema de archivos se pueden volver a montar tal como están; Las unidades sin formato (sin tabla de particiones) se pueden borrar y formatear para", "description": "Monte unidades USB para que puedan ser elegidas como objetivo local o Borg. Las unidades que ya tenían un sistema de archivos se pueden volver a montar tal como están; Las unidades sin formato (sin tabla de particiones) se pueden borrar y formatear para",
@@ -4185,7 +4196,7 @@
"typeDeviceExactly": "Escriba la ruta del dispositivo EXACTAMENTE para confirmar:", "typeDeviceExactly": "Escriba la ruta del dispositivo EXACTAMENTE para confirmar:",
"typeDeviceToConfirm": "Escriba la ruta del dispositivo para confirmar", "typeDeviceToConfirm": "Escriba la ruta del dispositivo para confirmar",
"unformatted": "Sin formato", "unformatted": "Sin formato",
"unmountConfirm": "¿Desmontar {path}? Cualquier trabajo de copia de seguridad que apunte a esta ruta fallará hasta que lo vuelva a montar.", "unmountConfirm": "¿Desmontar {path}? Cualquier tarea de copia de seguridad que apunte a esta ruta fallará hasta que lo vuelva a montar.",
"unmounted": "Desmontado", "unmounted": "Desmontado",
"uuidTitle": "uuid: {uuid}", "uuidTitle": "uuid: {uuid}",
"wipeAndFormat": "Limpiar y formatear" "wipeAndFormat": "Limpiar y formatear"
@@ -4217,15 +4228,15 @@
"description": "Comprobaciones detalladas de todos los componentes del sistema.", "description": "Comprobaciones detalladas de todos los componentes del sistema.",
"status": { "status": {
"ok": "OK", "ok": "OK",
"info": "Información", "info": "Info",
"warning": "Advertencia", "warning": "Advertencia",
"critical": "Crítico", "critical": "Crítico",
"unknown": "Desconocido" "unknown": "Desconocido"
}, },
"stats": { "stats": {
"total": "Total", "total": "Total",
"healthy": "Saludable", "healthy": "OK",
"info": "Información", "info": "Info",
"warning": "Advertencia", "warning": "Advertencia",
"critical": "Crítico", "critical": "Crítico",
"unknown": "Desconocido" "unknown": "Desconocido"
@@ -4287,7 +4298,7 @@
"certificateValid": "El certificado es válido", "certificateValid": "El certificado es válido",
"clusterDetected": "Clúster detectado (corosync.conf está presente)", "clusterDetected": "Clúster detectado (corosync.conf está presente)",
"active": "Activo", "active": "Activo",
"up": "ARRIBA", "up": "UP",
"kernelUpToDate": "Kernel/PVE está actualizado", "kernelUpToDate": "Kernel/PVE está actualizado",
"proxmoxUpToDate": "Proxmox VE está actualizado", "proxmoxUpToDate": "Proxmox VE está actualizado",
"noSecurityUpdates": "No hay actualizaciones de seguridad pendientes", "noSecurityUpdates": "No hay actualizaciones de seguridad pendientes",
@@ -4299,7 +4310,7 @@
"gatewayLatency": "Latencia a la puerta de enlace: {latency} ms", "gatewayLatency": "Latencia a la puerta de enlace: {latency} ms",
"failedLogins": "{count} intentos fallidos de inicio de sesión en 24 horas", "failedLogins": "{count} intentos fallidos de inicio de sesión en 24 horas",
"fail2banBannedIps": "Fail2Ban actualmente está bloqueando {count} direcciones IP (cárceles: {jails})", "fail2banBannedIps": "Fail2Ban actualmente está bloqueando {count} direcciones IP (cárceles: {jails})",
"uptimeDays": "Tiempo de actividad: {count} días", "uptimeDays": "Uptime: {count} días",
"pendingPackages": "{count} paquetes pendientes", "pendingPackages": "{count} paquetes pendientes",
"updatedDaysAgo": "Última actualización hace {count} días", "updatedDaysAgo": "Última actualización hace {count} días",
"storageAvailable": "{type} almacenamiento está disponible", "storageAvailable": "{type} almacenamiento está disponible",
+13 -16
View File
@@ -1194,8 +1194,8 @@
"applyOsUpdate": "Appliquer la mise à jour du système d'exploitation", "applyOsUpdate": "Appliquer la mise à jour du système d'exploitation",
"osUpToDate": "OS à jour", "osUpToDate": "OS à jour",
"installedByHelperPrefix": "Installé par", "installedByHelperPrefix": "Installé par",
"helperScriptsName": "Proxmox Helper-Scripts", "helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "- les mises à jour exécutent l'assistant de scripts de communauté.", "helperUpdatesRun": "Installé par Proxmox Helper-Scripts.",
"installedPrefix": "installé", "installedPrefix": "installé",
"upstreamAvailable": "version {version} disponible", "upstreamAvailable": "version {version} disponible",
"upToDateAt": "À jour à", "upToDateAt": "À jour à",
@@ -1409,14 +1409,7 @@
"alsoDetectedContainer": "Également détecté sur ce conteneur", "alsoDetectedContainer": "Également détecté sur ce conteneur",
"addAnotherApplication": "Ajouter une autre application", "addAnotherApplication": "Ajouter une autre application",
"doneButton": "Fait", "doneButton": "Fait",
"editButton": "Modifier", "editButton": "Modifier"
"upstreamErrorTimeout": "expiration du délai d'attente du réseau lors du contact en amont",
"upstreamErrorNetwork": "Erreur réseau : {detail}",
"upstreamErrorGeneric": "Échec de la vérification en amont : {detail}",
"notificationsEnabled": "Notifications de mise à jour en amont activées  cliquez pour désactiver le son",
"notificationsMuted": "Notifications de mise à jour en amont MUTED  cliquez pour activer",
"notifyUpstreamLabel": "Me prévenir lorsqu'une nouvelle version en amont est disponible",
"notifyUpstreamHelp": "envoie `app_update_available` aux canaux activés dans Paramètres → Notifications.Désactivez-la si cette application ne peut pas être mise à jour sur votre box."
} }
}, },
"settings": { "settings": {
@@ -1428,9 +1421,9 @@
}, },
"interfaceLanguage": { "interfaceLanguage": {
"title": "Langue de l'interface", "title": "Langue de l'interface",
"description": "Choisissez la langue utilisée par le tableau de bord Monitor. Les traductions manquantes reviennent à l'anglais.", "description": "Choisissez la langue utilisée par le tableau de bord du Monitor.",
"label": "Langue du tableau de bord", "label": "Langue du tableau de bord",
"fallbackNote": "Le texte non traduit est affiché en anglais jusqu'à ce que la communauté le remplisse.", "fallbackNote": "Traduction automatique via Google Translate. Signalez les erreurs dans les issues du projet ou envoyez une PR avec la correction.",
"statusComplete": "complet", "statusComplete": "complet",
"statusPartial": "partiel", "statusPartial": "partiel",
"statusNeedsTranslation": "traduction communautaire nécessaire" "statusNeedsTranslation": "traduction communautaire nécessaire"
@@ -1686,8 +1679,7 @@
"post_install_update": "Mises à jour d'optimisation de ProxMenux disponibles", "post_install_update": "Mises à jour d'optimisation de ProxMenux disponibles",
"secure_gateway_update_available": "Mise à jour de Secure Gateway disponible", "secure_gateway_update_available": "Mise à jour de Secure Gateway disponible",
"nvidia_driver_update_available": "Mise à jour du pilote NVIDIA disponible", "nvidia_driver_update_available": "Mise à jour du pilote NVIDIA disponible",
"coral_driver_update_available": "Mise à jour du pilote Coral TPU disponible", "coral_driver_update_available": "Mise à jour du pilote Coral TPU disponible"
"app_update_available": "mise à jour de l'application disponible"
}, },
"ui": { "ui": {
"quietHours": "Heures calmes", "quietHours": "Heures calmes",
@@ -2995,7 +2987,13 @@
"dontShowAgain": "Ne plus afficher pour cette version", "dontShowAgain": "Ne plus afficher pour cette version",
"currentFeatures": { "currentFeatures": {
"hostUpdate": "Mise à jour de l'hôte en un clic depuis Health Monitor. Le nouveau bouton Mettre à jour maintenant dans les mises à jour système exécute le flux de mise à jour Proxmox dans un terminal de tableau de bord, sans quitter le navigateur.", "hostUpdate": "Mise à jour de l'hôte en un clic depuis Health Monitor. Le nouveau bouton Mettre à jour maintenant dans les mises à jour système exécute le flux de mise à jour Proxmox dans un terminal de tableau de bord, sans quitter le navigateur.",
"mobileInstall": "Invite d'installation dans l'application pour mobile. Les nouveaux visiteurs sur Android et iOS Safari voient désormais des étapes simples pour ajouter le moniteur à leur écran d'accueil en tant que PWA." "mobileInstall": "Invite d'installation dans l'application pour mobile. Les nouveaux visiteurs sur Android et iOS Safari voient désormais des étapes simples pour ajouter le moniteur à leur écran d'accueil en tant que PWA.",
"pageSpeed": "",
"appTab": "",
"updatesTab": "",
"backupNoTimeout": "",
"vmDiskUsage": "",
"pwaInstall": ""
} }
}, },
"network": { "network": {
@@ -3530,7 +3528,6 @@
"deletePbsDescription": "Cela supprime l'instantané PBS de la banque de données.", "deletePbsDescription": "Cela supprime l'instantané PBS de la banque de données.",
"deletePbsTitle": "Supprimer l'instantané PBS", "deletePbsTitle": "Supprimer l'instantané PBS",
"companionFile": "fichier compagnon", "companionFile": "fichier compagnon",
"descriptionAfter": "",
"descriptionBefore": "Parcourez et restaurez les sauvegardes trouvées dans", "descriptionBefore": "Parcourez et restaurez les sauvegardes trouvées dans",
"downloadTitle": "Téléchargez cette sauvegarde", "downloadTitle": "Téléchargez cette sauvegarde",
"emptyAfter": "sauvegardes encore.", "emptyAfter": "sauvegardes encore.",
+13 -16
View File
@@ -1194,8 +1194,8 @@
"applyOsUpdate": "Applica l'aggiornamento del sistema operativo", "applyOsUpdate": "Applica l'aggiornamento del sistema operativo",
"osUpToDate": "Sistema operativo aggiornato", "osUpToDate": "Sistema operativo aggiornato",
"installedByHelperPrefix": "Installato da", "installedByHelperPrefix": "Installato da",
"helperScriptsName": "Proxmox Helper-Scripts", "helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "- gli aggiornamenti eseguono l'helper degli script della comunità.", "helperUpdatesRun": "Installato da Proxmox Helper-Scripts.",
"installedPrefix": "installato", "installedPrefix": "installato",
"upstreamAvailable": "versione {version} disponibile", "upstreamAvailable": "versione {version} disponibile",
"upToDateAt": "Aggiornato a", "upToDateAt": "Aggiornato a",
@@ -1409,14 +1409,7 @@
"alsoDetectedContainer": "Rilevato anche su questo contenitore", "alsoDetectedContainer": "Rilevato anche su questo contenitore",
"addAnotherApplication": "Aggiungi un'altra applicazione", "addAnotherApplication": "Aggiungi un'altra applicazione",
"doneButton": "Fatto", "doneButton": "Fatto",
"editButton": "Modificare", "editButton": "Modificare"
"upstreamErrorTimeout": "timeout della rete durante il contatto a monte",
"upstreamErrorNetwork": "errore di rete: {detail}",
"upstreamErrorGeneric": "controllo upstream non riuscito: {detail}",
"notificationsEnabled": "notifiche di aggiornamento upstream attivate: fai clic per disattivare l'audio",
"notificationsMuted": "notifiche di aggiornamento upstream MUTED: fare clic per abilitare",
"notifyUpstreamLabel": "avvisami quando è disponibile una nuova versione upstream",
"notifyUpstreamHelp": "invia `app_update_available` ai canali abilitati in Impostazioni → Notifiche.Disattiva se questa app non può essere aggiornata sul tuo box."
} }
}, },
"settings": { "settings": {
@@ -1428,9 +1421,9 @@
}, },
"interfaceLanguage": { "interfaceLanguage": {
"title": "Linguaggio dell'interfaccia", "title": "Linguaggio dell'interfaccia",
"description": "Scegli la lingua utilizzata dalla dashboard Monitor. Le traduzioni mancanti ricadono in inglese.", "description": "Scegli la lingua utilizzata dal pannello del Monitor.",
"label": "Lingua del dashboard", "label": "Lingua del dashboard",
"fallbackNote": "Il testo non tradotto viene mostrato in inglese finché la community non lo completa.", "fallbackNote": "Traduzione automatica con Google Translate. Segnala eventuali errori nelle issues del progetto o invia una PR con la correzione.",
"statusComplete": "completare", "statusComplete": "completare",
"statusPartial": "parziale", "statusPartial": "parziale",
"statusNeedsTranslation": "è necessaria la traduzione della comunità" "statusNeedsTranslation": "è necessaria la traduzione della comunità"
@@ -1686,8 +1679,7 @@
"post_install_update": "Aggiornamenti di ottimizzazione ProxMenux disponibili", "post_install_update": "Aggiornamenti di ottimizzazione ProxMenux disponibili",
"secure_gateway_update_available": "Aggiornamento Secure Gateway disponibile", "secure_gateway_update_available": "Aggiornamento Secure Gateway disponibile",
"nvidia_driver_update_available": "Aggiornamento del driver NVIDIA disponibile", "nvidia_driver_update_available": "Aggiornamento del driver NVIDIA disponibile",
"coral_driver_update_available": "Disponibile l'aggiornamento del driver Coral TPU", "coral_driver_update_available": "Disponibile l'aggiornamento del driver Coral TPU"
"app_update_available": "aggiornamento dell'app disponibile"
}, },
"ui": { "ui": {
"quietHours": "Ore tranquille", "quietHours": "Ore tranquille",
@@ -2995,7 +2987,13 @@
"dontShowAgain": "Non mostrare più per questa versione", "dontShowAgain": "Non mostrare più per questa versione",
"currentFeatures": { "currentFeatures": {
"hostUpdate": "Aggiornamento host con un clic da Health Monitor. Il nuovo pulsante Aggiorna ora in Aggiornamenti di sistema esegue il flusso di aggiornamento di Proxmox all'interno di un terminale dashboard, senza uscire dal browser.", "hostUpdate": "Aggiornamento host con un clic da Health Monitor. Il nuovo pulsante Aggiorna ora in Aggiornamenti di sistema esegue il flusso di aggiornamento di Proxmox all'interno di un terminale dashboard, senza uscire dal browser.",
"mobileInstall": "Richiesta di installazione in-app per dispositivi mobili. Chi visita per la prima volta Android e iOS Safari ora vede semplici passaggi per aggiungere il monitor alla propria schermata iniziale come PWA." "mobileInstall": "Richiesta di installazione in-app per dispositivi mobili. Chi visita per la prima volta Android e iOS Safari ora vede semplici passaggi per aggiungere il monitor alla propria schermata iniziale come PWA.",
"pageSpeed": "",
"appTab": "",
"updatesTab": "",
"backupNoTimeout": "",
"vmDiskUsage": "",
"pwaInstall": ""
} }
}, },
"network": { "network": {
@@ -3530,7 +3528,6 @@
"deletePbsDescription": "Ciò rimuove lo snapshot PBS dall'archivio dati.", "deletePbsDescription": "Ciò rimuove lo snapshot PBS dall'archivio dati.",
"deletePbsTitle": "Elimina istantanea PBS", "deletePbsTitle": "Elimina istantanea PBS",
"companionFile": "file compagno", "companionFile": "file compagno",
"descriptionAfter": "",
"descriptionBefore": "Sfoglia e ripristina i backup trovati in", "descriptionBefore": "Sfoglia e ripristina i backup trovati in",
"downloadTitle": "Scarica questo backup", "downloadTitle": "Scarica questo backup",
"emptyAfter": "backup ancora.", "emptyAfter": "backup ancora.",
+13 -16
View File
@@ -1194,8 +1194,8 @@
"applyOsUpdate": "Aplicar atualização do sistema operacional", "applyOsUpdate": "Aplicar atualização do sistema operacional",
"osUpToDate": "SO atualizado", "osUpToDate": "SO atualizado",
"installedByHelperPrefix": "Instalado por", "installedByHelperPrefix": "Instalado por",
"helperScriptsName": "Proxmox Helper-Scripts", "helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "— as atualizações executam o auxiliar de scripts da comunidade.", "helperUpdatesRun": "Instalado pelo Proxmox Helper-Scripts.",
"installedPrefix": "instalado", "installedPrefix": "instalado",
"upstreamAvailable": "versão {version} disponível", "upstreamAvailable": "versão {version} disponível",
"upToDateAt": "Atualizado em", "upToDateAt": "Atualizado em",
@@ -1409,14 +1409,7 @@
"alsoDetectedContainer": "Também detectado neste contêiner", "alsoDetectedContainer": "Também detectado neste contêiner",
"addAnotherApplication": "Adicione outro aplicativo", "addAnotherApplication": "Adicione outro aplicativo",
"doneButton": "Feito", "doneButton": "Feito",
"editButton": "Editar", "editButton": "Editar"
"upstreamErrorTimeout": "Tempo limite da rede ao entrar em contato com o upstream",
"upstreamErrorNetwork": "Erro de rede: {detail}",
"upstreamErrorGeneric": "falha na verificação upstream: {detail}",
"notificationsEnabled": "Notificações de atualização upstream ATIVADAS clique para silenciar",
"notificationsMuted": "notificações de atualização upstream silenciadas clique para ativar",
"notifyUpstreamLabel": "Notifique-me quando uma nova versão upstream estiver disponível",
"notifyUpstreamHelp": "Envia `app_update_available` para os canais habilitados em Configurações → Notificações.Desligue se este aplicativo não puder ser atualizado em sua caixa."
} }
}, },
"settings": { "settings": {
@@ -1428,9 +1421,9 @@
}, },
"interfaceLanguage": { "interfaceLanguage": {
"title": "Idioma da interface", "title": "Idioma da interface",
"description": "Escolha o idioma usado pelo painel do Monitor. As traduções ausentes voltam para o inglês.", "description": "Escolha o idioma utilizado pelo painel do Monitor.",
"label": "Idioma do painel", "label": "Idioma do painel",
"fallbackNote": "O texto não traduzido é mostrado em inglês até que a comunidade o preencha.", "fallbackNote": "Tradução automática com Google Translate. Reporte erros nas issues do projeto ou envie um PR com a correção.",
"statusComplete": "completo", "statusComplete": "completo",
"statusPartial": "parcial", "statusPartial": "parcial",
"statusNeedsTranslation": "tradução comunitária necessária" "statusNeedsTranslation": "tradução comunitária necessária"
@@ -1686,8 +1679,7 @@
"post_install_update": "Atualizações de otimização ProxMenux disponíveis", "post_install_update": "Atualizações de otimização ProxMenux disponíveis",
"secure_gateway_update_available": "Atualização do Secure Gateway disponível", "secure_gateway_update_available": "Atualização do Secure Gateway disponível",
"nvidia_driver_update_available": "Atualização de driver NVIDIA disponível", "nvidia_driver_update_available": "Atualização de driver NVIDIA disponível",
"coral_driver_update_available": "Atualização do driver Coral TPU disponível", "coral_driver_update_available": "Atualização do driver Coral TPU disponível"
"app_update_available": "atualização de aplicativo disponível"
}, },
"ui": { "ui": {
"quietHours": "Horas tranquilas", "quietHours": "Horas tranquilas",
@@ -2995,7 +2987,13 @@
"dontShowAgain": "Não mostrar novamente para esta versão", "dontShowAgain": "Não mostrar novamente para esta versão",
"currentFeatures": { "currentFeatures": {
"hostUpdate": "Atualização do host com um clique no Health Monitor. O novo botão Atualizar agora em Atualizações do sistema executa o fluxo de atualização do Proxmox dentro de um terminal de painel, sem sair do navegador.", "hostUpdate": "Atualização do host com um clique no Health Monitor. O novo botão Atualizar agora em Atualizações do sistema executa o fluxo de atualização do Proxmox dentro de um terminal de painel, sem sair do navegador.",
"mobileInstall": "Prompt de instalação no aplicativo para celular. Visitantes iniciantes no Android e iOS Safari agora veem etapas simples para adicionar o Monitor à tela inicial como um PWA." "mobileInstall": "Prompt de instalação no aplicativo para celular. Visitantes iniciantes no Android e iOS Safari agora veem etapas simples para adicionar o Monitor à tela inicial como um PWA.",
"pageSpeed": "",
"appTab": "",
"updatesTab": "",
"backupNoTimeout": "",
"vmDiskUsage": "",
"pwaInstall": ""
} }
}, },
"network": { "network": {
@@ -3530,7 +3528,6 @@
"deletePbsDescription": "Isso remove o instantâneo do PBS do armazenamento de dados.", "deletePbsDescription": "Isso remove o instantâneo do PBS do armazenamento de dados.",
"deletePbsTitle": "Excluir instantâneo do PBS", "deletePbsTitle": "Excluir instantâneo do PBS",
"companionFile": "arquivo complementar", "companionFile": "arquivo complementar",
"descriptionAfter": "",
"descriptionBefore": "Procure e restaure backups encontrados em", "descriptionBefore": "Procure e restaure backups encontrados em",
"downloadTitle": "Baixe este backup", "downloadTitle": "Baixe este backup",
"emptyAfter": "backups ainda.", "emptyAfter": "backups ainda.",
+18 -20
View File
@@ -1,7 +1,7 @@
{ {
"app": { "app": {
"title": "ProxMenux Monitor", "title": "ProxMenux Monitor",
"description": "Systémový prehľad Proxmoxu", "description": "Proxmox System Dashboard",
"loading": "Načítava sa...", "loading": "Načítava sa...",
"connecting": "Pripájam sa k ProxMenux Monitoru", "connecting": "Pripájam sa k ProxMenux Monitoru",
"unknown": "Neznáme", "unknown": "Neznáme",
@@ -143,7 +143,7 @@
"year": "1 rok" "year": "1 rok"
}, },
"stats": { "stats": {
"avg": "priemer", "avg": "avg",
"max": "max", "max": "max",
"min": "min" "min": "min"
}, },
@@ -839,7 +839,7 @@
"topCount": "Prvých {shown} z {total} procesov", "topCount": "Prvých {shown} z {total} procesov",
"loadFailed": "Nepodarilo sa načítať procesy", "loadFailed": "Nepodarilo sa načítať procesy",
"lifetimeAverageTitle": "Priemerné CPU % za celý život procesu - užitočné pri hľadaní procesov, ktoré dlho bežia potichu na pozadí", "lifetimeAverageTitle": "Priemerné CPU % za celý život procesu - užitočné pri hľadaní procesov, ktoré dlho bežia potichu na pozadí",
"averageShort": "priem." "averageShort": "avg"
}, },
"processInfo": { "processInfo": {
"titleFallback": "Proces", "titleFallback": "Proces",
@@ -1193,8 +1193,8 @@
"applyOsUpdate": "Aktualizovať systém", "applyOsUpdate": "Aktualizovať systém",
"osUpToDate": "Systém je aktuálny", "osUpToDate": "Systém je aktuálny",
"installedByHelperPrefix": "Nainštalované cez", "installedByHelperPrefix": "Nainštalované cez",
"helperScriptsName": "Proxmox Helper-Scripts", "helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "— aktualizácie spustia pomocný skript z community-scripts.", "helperUpdatesRun": "Inštalované pomocou Proxmox Helper-Scripts.",
"installedPrefix": "nainštalovaná verzia", "installedPrefix": "nainštalovaná verzia",
"upstreamAvailable": "dostupná verzia {version}", "upstreamAvailable": "dostupná verzia {version}",
"upToDateAt": "Aktuálna verzia", "upToDateAt": "Aktuálna verzia",
@@ -1408,14 +1408,7 @@
"alsoDetectedContainer": "Ďalšie aplikácie nájdené v kontajneri", "alsoDetectedContainer": "Ďalšie aplikácie nájdené v kontajneri",
"addAnotherApplication": "Pridať ďalšiu aplikáciu", "addAnotherApplication": "Pridať ďalšiu aplikáciu",
"doneButton": "Hotovo", "doneButton": "Hotovo",
"editButton": "Upraviť", "editButton": "Upraviť"
"upstreamErrorTimeout": "Technický text používateľského rozhrania pre riadiaci panel Proxmox.Preložiť: Časový limit siete pri kontaktovaní upstream",
"upstreamErrorNetwork": "Technický text používateľského rozhrania pre riadiaci panel Proxmox.Preložiť: Chyba siete: {detail}",
"upstreamErrorGeneric": "Technický text používateľského rozhrania pre riadiaci panel Proxmox.Preložiť: Kontrola proti prúdu zlyhala: {detail}",
"notificationsEnabled": "Technický text používateľského rozhrania pre riadiaci panel Proxmox.Preložiť: Upozornenia na upstream aktualizácie sú ZAPNUTÉ kliknutím ich stlmíte",
"notificationsMuted": "Technický text používateľského rozhrania pre riadiaci panel Proxmox.Preložiť: Upstream upozornenia na aktualizácie MUTED kliknutím povolíte",
"notifyUpstreamLabel": "Technický text používateľského rozhrania pre riadiaci panel Proxmox.Preložiť: Upozorniť ma, keď bude k dispozícii nová upstream verzia",
"notifyUpstreamHelp": "Technický text používateľského rozhrania pre riadiaci panel Proxmox.Preložiť: Odošle `app_update_available` do kanálov povolených v Nastaveniach → Upozornenia.Vypnite, ak túto aplikáciu nie je možné aktualizovať na vašom boxe."
} }
}, },
"settings": { "settings": {
@@ -1427,9 +1420,9 @@
}, },
"interfaceLanguage": { "interfaceLanguage": {
"title": "Jazyk rozhrania", "title": "Jazyk rozhrania",
"description": "Vyberte jazyk, ktorý bude používať Monitor dashboard. Texty bez prekladu sa zobrazia po anglicky.", "description": "Vyberte jazyk používaný v paneli Monitor.",
"label": "Jazyk dashboardu", "label": "Jazyk dashboardu",
"fallbackNote": "Nepreložené texty zostanú po anglicky, kým ich komunita nedoplní.", "fallbackNote": "",
"statusComplete": "hotové", "statusComplete": "hotové",
"statusPartial": "čiastočne preložené", "statusPartial": "čiastočne preložené",
"statusNeedsTranslation": "čaká na komunitný preklad" "statusNeedsTranslation": "čaká na komunitný preklad"
@@ -1685,8 +1678,7 @@
"post_install_update": "Dostupné aktualizácie optimalizácií ProxMenux", "post_install_update": "Dostupné aktualizácie optimalizácií ProxMenux",
"secure_gateway_update_available": "K dispozícii je aktualizácia Secure Gateway", "secure_gateway_update_available": "K dispozícii je aktualizácia Secure Gateway",
"nvidia_driver_update_available": "Dostupná aktualizácia ovládača NVIDIA", "nvidia_driver_update_available": "Dostupná aktualizácia ovládača NVIDIA",
"coral_driver_update_available": "Dostupná aktualizácia ovládača Coral TPU", "coral_driver_update_available": "Dostupná aktualizácia ovládača Coral TPU"
"app_update_available": "Technický text používateľského rozhrania pre riadiaci panel Proxmox.Preložiť: K dispozícii je aktualizácia aplikácie"
}, },
"ui": { "ui": {
"quietHours": "Tiché hodiny", "quietHours": "Tiché hodiny",
@@ -2994,7 +2986,13 @@
"dontShowAgain": "Túto verziu už nezobrazovať", "dontShowAgain": "Túto verziu už nezobrazovať",
"currentFeatures": { "currentFeatures": {
"hostUpdate": "Aktualizácia hosta jedným kliknutím z kontroly stavu. Nové tlačidlo Aktualizovať teraz v časti systémových aktualizácií spustí aktualizáciu Proxmoxu priamo v termináli dashboardu, bez odchodu z prehliadača.", "hostUpdate": "Aktualizácia hosta jedným kliknutím z kontroly stavu. Nové tlačidlo Aktualizovať teraz v časti systémových aktualizácií spustí aktualizáciu Proxmoxu priamo v termináli dashboardu, bez odchodu z prehliadača.",
"mobileInstall": "Výzva na inštaláciu aplikácie v mobile. Prví návštevníci v Androide a iOS Safari uvidia jednoduchý spodný panel s krokmi na pridanie Monitoru na domovskú obrazovku." "mobileInstall": "Výzva na inštaláciu aplikácie v mobile. Prví návštevníci v Androide a iOS Safari uvidia jednoduchý spodný panel s krokmi na pridanie Monitoru na domovskú obrazovku.",
"pageSpeed": "",
"appTab": "",
"updatesTab": "",
"backupNoTimeout": "",
"vmDiskUsage": "",
"pwaInstall": ""
} }
}, },
"network": { "network": {
@@ -4216,7 +4214,7 @@
"title": "Stav systému", "title": "Stav systému",
"description": "Podrobná kontrola všetkých častí systému", "description": "Podrobná kontrola všetkých častí systému",
"status": { "status": {
"ok": "V poriadku", "ok": "OK",
"info": "Info", "info": "Info",
"warning": "Upozornenie", "warning": "Upozornenie",
"critical": "Problém", "critical": "Problém",
@@ -4226,7 +4224,7 @@
"total": "Spolu", "total": "Spolu",
"healthy": "V poriadku", "healthy": "V poriadku",
"info": "Info", "info": "Info",
"warning": "Upozornenia", "warning": "Upozornenie",
"critical": "Problémy", "critical": "Problémy",
"unknown": "Neznáme" "unknown": "Neznáme"
}, },
+13 -15
View File
@@ -1194,8 +1194,8 @@
"applyOsUpdate": "Använd OS-uppdatering", "applyOsUpdate": "Använd OS-uppdatering",
"osUpToDate": "OS uppdaterat", "osUpToDate": "OS uppdaterat",
"installedByHelperPrefix": "Installerad av", "installedByHelperPrefix": "Installerad av",
"helperScriptsName": "Proxmox Helper-Scripts", "helperScriptsName": "Proxmox VE Helper-Scripts",
"helperUpdatesRun": "— uppdateringar kör community-scripts helper.", "helperUpdatesRun": "Installerad av Proxmox Helper-Scripts.",
"installedPrefix": "installerat", "installedPrefix": "installerat",
"upstreamAvailable": "version {version} tillgänglig", "upstreamAvailable": "version {version} tillgänglig",
"upToDateAt": "Uppdaterad kl", "upToDateAt": "Uppdaterad kl",
@@ -1409,14 +1409,7 @@
"alsoDetectedContainer": "Detekteras även på denna behållare", "alsoDetectedContainer": "Detekteras även på denna behållare",
"addAnotherApplication": "Lägg till ytterligare ett program", "addAnotherApplication": "Lägg till ytterligare ett program",
"doneButton": "Gjort", "doneButton": "Gjort",
"editButton": "Redigera", "editButton": "Redigera"
"upstreamErrorTimeout": "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Översätt: Nätverkstimeout vid kontakt uppströms",
"upstreamErrorNetwork": "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Översätt: Nätverksfel: {detail}",
"upstreamErrorGeneric": "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Översätt: Uppströmskontroll misslyckades: {detail}",
"notificationsEnabled": "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Översätt: Uppströmsuppdateringsmeddelanden PÅ klicka för att stänga av ljudet",
"notificationsMuted": "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Översätt: Uppströmsuppdateringsmeddelanden AVSTÄLLD klicka för att aktivera",
"notifyUpstreamLabel": "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Översätt: Meddela mig när en ny uppströmsversion är tillgänglig",
"notifyUpstreamHelp": "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Översätt: Skickar `app_update_available` till de kanaler som är aktiverade i Inställningar → Aviseringar.Stäng av om den här appen inte kan uppdateras på din box."
} }
}, },
"settings": { "settings": {
@@ -1428,9 +1421,9 @@
}, },
"interfaceLanguage": { "interfaceLanguage": {
"title": "Gränssnittsspråk", "title": "Gränssnittsspråk",
"description": "Välj det språk som används av Monitor-instrumentpanelen. Saknade översättningar faller tillbaka till engelska.", "description": "Välj språket som används av Monitor-panelen.",
"label": "Språk på instrumentpanelen", "label": "Språk på instrumentpanelen",
"fallbackNote": "Oöversatt text visas på engelska tills communityn fyller i den.", "fallbackNote": "",
"statusComplete": "komplett", "statusComplete": "komplett",
"statusPartial": "partiell", "statusPartial": "partiell",
"statusNeedsTranslation": "gemenskapsöversättning behövs" "statusNeedsTranslation": "gemenskapsöversättning behövs"
@@ -1686,8 +1679,7 @@
"post_install_update": "ProxMenux optimeringsuppdateringar tillgängliga", "post_install_update": "ProxMenux optimeringsuppdateringar tillgängliga",
"secure_gateway_update_available": "Secure Gateway uppdatering tillgänglig", "secure_gateway_update_available": "Secure Gateway uppdatering tillgänglig",
"nvidia_driver_update_available": "NVIDIA drivrutinsuppdatering tillgänglig", "nvidia_driver_update_available": "NVIDIA drivrutinsuppdatering tillgänglig",
"coral_driver_update_available": "Coral TPU drivrutinsuppdatering tillgänglig", "coral_driver_update_available": "Coral TPU drivrutinsuppdatering tillgänglig"
"app_update_available": "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Översätt: Appuppdatering tillgänglig"
}, },
"ui": { "ui": {
"quietHours": "Tysta timmar", "quietHours": "Tysta timmar",
@@ -2995,7 +2987,13 @@
"dontShowAgain": "Visa inte igen för den här versionen", "dontShowAgain": "Visa inte igen för den här versionen",
"currentFeatures": { "currentFeatures": {
"hostUpdate": "Värduppdatering med ett klick från Health Monitor. Den nya knappen Uppdatera nu i Systemuppdateringar kör Proxmox-uppdateringsflödet i en instrumentpanelsterminal utan att lämna webbläsaren.", "hostUpdate": "Värduppdatering med ett klick från Health Monitor. Den nya knappen Uppdatera nu i Systemuppdateringar kör Proxmox-uppdateringsflödet i en instrumentpanelsterminal utan att lämna webbläsaren.",
"mobileInstall": "Uppmaning om installation i appen för mobil. Förstagångsbesökare på Android och iOS Safari ser nu enkla steg för att lägga till monitorn på sin startskärm som en PWA." "mobileInstall": "Uppmaning om installation i appen för mobil. Förstagångsbesökare på Android och iOS Safari ser nu enkla steg för att lägga till monitorn på sin startskärm som en PWA.",
"pageSpeed": "",
"appTab": "",
"updatesTab": "",
"backupNoTimeout": "",
"vmDiskUsage": "",
"pwaInstall": ""
} }
}, },
"network": { "network": {
+137 -78
View File
@@ -1616,11 +1616,30 @@ _vm_backups_cache: dict = {} # vmid -> (ts, payload)
_vm_apps_cache: dict = {} # vmid -> (ts, payload) _vm_apps_cache: dict = {} # vmid -> (ts, payload)
_vm_schedule_cache: dict = {} # vmid -> (ts, payload) _vm_schedule_cache: dict = {} # vmid -> (ts, payload)
_vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only _vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
_VM_DETAILS_TTL = 300 # config rarely changes without a user action # Effective TTL is "indefinite": these caches are refreshed only
_VM_BACKUPS_TTL = 120 # storage scans — a new backup is an event # by explicit event-based invalidation (`_vm_cache_invalidate` calls
_VM_APPS_TTL = 600 # LXC apps register/unregister is rare # on start/stop/reboot, add/edit/delete app, apply update, edit
_VM_SCHEDULE_TTL = 900 # persisted schedule almost never changes # schedule, create backup). No periodic poll — the prewarmer runs
_VM_MOUNTS_TTL = 600 # mpX entries only change on manual edit # once at startup and then stays quiet. The rationale is that all
# of these payloads are backed by files (.conf / sidecar JSON /
# storage inventory) that only change through actions the Monitor
# either performs itself (invalidates in-line) or that require a
# guest restart (start/stop invalidates too). Runtime data that DOES
# change without an invalidation event lives in a different path:
# - Live CPU/mem/disk/network per guest → served by /api/vms (SWR
# poll every 2.5 s from the client, no cache here).
# - Firewall log → served on-demand, no cache at all.
# - Mount points runtime (df/stat/ad-hoc) → to be split into a
# separate always-fresh endpoint (Fase 5).
# - Backups appearing outside Monitor (cron/scheduled/retention)
# → client passes ?fresh=1 when its own cache is older than 6 h
# (Fase 4).
_VM_CACHE_INDEFINITE = 315_360_000 # 10 years — effectively infinite
_VM_DETAILS_TTL = _VM_CACHE_INDEFINITE
_VM_BACKUPS_TTL = _VM_CACHE_INDEFINITE
_VM_APPS_TTL = _VM_CACHE_INDEFINITE
_VM_SCHEDULE_TTL = _VM_CACHE_INDEFINITE
_VM_MOUNTS_TTL = _VM_CACHE_INDEFINITE
_vm_modal_cache_lock = threading.Lock() _vm_modal_cache_lock = threading.Lock()
def _vm_cache_get(cache: dict, vmid: int, ttl: int): def _vm_cache_get(cache: dict, vmid: int, ttl: int):
@@ -11506,7 +11525,14 @@ def _node_metrics_prewarmer_loop():
def _vm_modal_prewarmer_pass(): def _vm_modal_prewarmer_pass():
"""One full sweep of every guest's modal caches. Called both """One full sweep of every guest's modal caches. Called both
from the startup warm-up and from the recurring loop. Returns from the startup warm-up and from the recurring loop. Returns
the number of guests successfully touched.""" the number of guests successfully touched.
The recurring loop is deliberately cheap: for each guest and
each cache we check the TTL FIRST and only invoke the handler
when the entry is actually stale. On steady state (all caches
fresh) a pass is O(guests) dict reads with no request contexts
created, no handlers entered, no pvesh spawned the CPU cost
disappears until something genuinely expires."""
from flask import g as _flask_g from flask import g as _flask_g
warmed = 0 warmed = 0
resources = get_cached_pvesh_cluster_resources_vm() or [] resources = get_cached_pvesh_cluster_resources_vm() or []
@@ -11515,83 +11541,74 @@ def _vm_modal_prewarmer_pass():
vm_type = r.get('type') # 'qemu' or 'lxc' vm_type = r.get('type') # 'qemu' or 'lxc'
if vmid is None: if vmid is None:
continue continue
try:
with app.test_request_context(f'/api/vms/{vmid}'): endpoints = [
_flask_g._internal_call = True (_vm_details_cache, _VM_DETAILS_TTL, get_vm_config,
get_vm_config(vmid) f'/api/vms/{vmid}', 'details'),
except Exception as e: (_vm_backups_cache, _VM_BACKUPS_TTL, api_vm_backups,
print(f"[ProxMenux] vm-modal prewarmer details {vmid}: {e}", f'/api/vms/{vmid}/backups', 'backups'),
file=sys.stderr, flush=True) ]
try:
with app.test_request_context(f'/api/vms/{vmid}/backups'):
_flask_g._internal_call = True
api_vm_backups(vmid)
except Exception as e:
print(f"[ProxMenux] vm-modal prewarmer backups {vmid}: {e}",
file=sys.stderr, flush=True)
if vm_type == 'lxc': if vm_type == 'lxc':
endpoints.extend([
(_vm_apps_cache, _VM_APPS_TTL, api_vm_apps_get,
f'/api/vms/{vmid}/apps', 'apps'),
(_vm_schedule_cache, _VM_SCHEDULE_TTL, api_vm_apps_schedule,
f'/api/vms/{vmid}/schedule', 'schedule'),
(_vm_mounts_cache, _VM_MOUNTS_TTL, api_lxc_mount_points,
f'/api/lxc/{vmid}/mount-points', 'mounts'),
])
did_work = False
for cache, ttl, handler, route, label in endpoints:
if _vm_cache_get(cache, vmid, ttl) is not None:
continue # still fresh — skip the request-context overhead
try: try:
with app.test_request_context(f'/api/vms/{vmid}/apps'): with app.test_request_context(route):
_flask_g._internal_call = True _flask_g._internal_call = True
api_vm_apps_get(vmid) handler(vmid)
did_work = True
except Exception as e: except Exception as e:
print(f"[ProxMenux] vm-modal prewarmer apps {vmid}: {e}", print(f"[ProxMenux] vm-modal prewarmer {label} {vmid}: {e}",
file=sys.stderr, flush=True)
try:
with app.test_request_context(f'/api/vms/{vmid}/schedule'):
_flask_g._internal_call = True
api_vm_apps_schedule(vmid)
except Exception as e:
print(f"[ProxMenux] vm-modal prewarmer schedule {vmid}: {e}",
file=sys.stderr, flush=True)
try:
with app.test_request_context(f'/api/lxc/{vmid}/mount-points'):
_flask_g._internal_call = True
api_lxc_mount_points(vmid)
except Exception as e:
print(f"[ProxMenux] vm-modal prewarmer mounts {vmid}: {e}",
file=sys.stderr, flush=True) file=sys.stderr, flush=True)
warmed += 1 warmed += 1
time.sleep(0.2) # brief breath so pvesh isn't hammered if did_work:
time.sleep(0.2) # breath only when we actually ran a handler
return warmed return warmed
def _vm_modal_prewarmer_loop(): def _vm_modal_prewarmer_loop():
"""Keep the per-VM modal caches (details / backups / apps / """One-shot warmup at service startup. Populates every per-VM
schedule) hot from the backend, so modals always open instantly modal cache (details / backups / apps / schedule / mount points)
even after the browser tab has been closed for a long time. exactly once, then exits no periodic refresh loop.
The old React prefetcher only ran while the page was open.
Design (matches user's expectation of "heavy at startup, near- Rationale: every cache in this family is refreshed by explicit
zero during runtime"): event invalidation (see `_vm_cache_invalidate` calls scattered
* One full warm-up pass right after startup so every cache is across write endpoints). A periodic loop was double work and
primed before the user opens the UI. lit up the CPU on hosts with many guests. The previous 5 min
* After that, a slow refresh loop at 180 s intervals. Since tick meant ~500-1000 background subprocess/pvesh calls per hour
the underlying TTLs are 5-15 min, the vast majority of on a 25-guest host, entirely for data that hadn't changed.
these calls are cache-hits (essentially free); real work
only happens when a cache is about to expire.
* Write actions (start/stop/reboot, apply update, edit
schedule) call `_vm_cache_invalidate(vmid, ...)` the
loop then refreshes just that guest on its next tick, and
an on-demand user open refreshes it immediately.
LXC-only endpoints (apps, schedule) are skipped for qemu VMs.""" Trade-offs handled elsewhere:
* Backups added out-of-band (cron / scheduled / retention)
client passes `?fresh=1` on modal open when its local
cache is older than 6 h; server ignores the indefinite TTL
for that call and re-scans (Fase 4).
* Mount points runtime state (df/stat/ad-hoc) that changes
continuously served by a separate always-fresh endpoint
the client fetches on modal open (Fase 5).
Called once from the startup section. Thread exits after the
initial pass no `while True` loop."""
time.sleep(3) # let Flask finish binding before we invoke handlers time.sleep(3) # let Flask finish binding before we invoke handlers
try: try:
t0 = time.time() t0 = time.time()
n = _vm_modal_prewarmer_pass() n = _vm_modal_prewarmer_pass()
print(f"[ProxMenux] VM-modal prewarmer: initial warm-up complete " print(f"[ProxMenux] VM-modal prewarmer: warm-up complete "
f"({n} guests in {time.time()-t0:.1f}s)", flush=True) f"({n} guests in {time.time()-t0:.1f}s) — no periodic refresh, "
f"caches held by event invalidation only", flush=True)
except Exception as e: except Exception as e:
print(f"[ProxMenux] VM-modal prewarmer initial pass failed: {e}", print(f"[ProxMenux] VM-modal prewarmer initial pass failed: {e}",
file=sys.stderr, flush=True) file=sys.stderr, flush=True)
while True:
time.sleep(180) # 3 min — most passes are cache-hits, near-zero cost
try:
_vm_modal_prewarmer_pass()
except Exception as e:
print(f"[ProxMenux] VM-modal prewarmer refresh error: {e}",
file=sys.stderr, flush=True)
@app.route('/api/node/metrics', methods=['GET']) @app.route('/api/node/metrics', methods=['GET'])
@@ -12569,11 +12586,26 @@ def api_create_backup(vmid):
@app.route('/api/vms/<int:vmid>/backups', methods=['GET']) @app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
@require_auth @require_auth
def api_vm_backups(vmid): def api_vm_backups(vmid):
"""Get list of backups for a specific VM/LXC""" """Get list of backups for a specific VM/LXC.
The backend cache is indefinite (event-invalidated only). Out-of-
band backups cron jobs, scheduled vzdump, PBS retention pruning
never call `_vm_cache_invalidate`, so a naked GET would keep
serving the last snapshot for hours after a new file appeared.
To reconcile that without a background poll, the client tracks
the age of its own copy and, when older than its 6-hour gate,
calls this endpoint with `?fresh=1`. Server ignores the cached
entry for that call, re-scans every storage, writes the result
back into the cache and returns it. Subsequent openings within
the next 6 hours hit the freshened cache instantly.
"""
try: try:
cached = _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL) force_fresh = request.args.get('fresh') in ('1', 'true', 'yes')
if cached is not None: if not force_fresh:
return jsonify(cached) cached = _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL)
if cached is not None:
return jsonify(cached)
backups = [] backups = []
@@ -13928,15 +13960,17 @@ def get_vm_config(vmid):
@app.route('/api/lxc/<int:vmid>/mount-points', methods=['GET']) @app.route('/api/lxc/<int:vmid>/mount-points', methods=['GET'])
@require_auth @require_auth
def api_lxc_mount_points(vmid): def api_lxc_mount_points(vmid):
"""Sprint 13.29: per-LXC mount points enumeration. """Static half of the per-LXC mount-points payload — parsed mp
entries, source/target, PVE storage classification, host source
existence flags. Runtime state (`df` capacity, `stat` health,
ad-hoc NFS/CIFS discovery, runtime_mounted flag) lives in the
sibling `/api/lxc/<vmid>/mount-points/runtime` endpoint that
the client fetches on demand every time the tab opens.
Returns the parsed ``mpX:`` entries from the container config plus, Backed by the indefinite `_vm_mounts_cache` (invalidated on
when the container is running, runtime status (mounted/not, real start/stop of the guest, since config-visible fields normally
fstype, options, stale detection) and any ad-hoc NFS/CIFS/SMB the only change through a guest reboot). The runtime endpoint is
user mounted from inside the CT. Capacity is always populated from NEVER cached it must reflect the live state at click time."""
the host-side source (PVE storage or `df` of the host path) so the
info is meaningful even on stopped containers.
"""
cached = _vm_cache_get(_vm_mounts_cache, vmid, _VM_MOUNTS_TTL) cached = _vm_cache_get(_vm_mounts_cache, vmid, _VM_MOUNTS_TTL)
if cached is not None: if cached is not None:
return jsonify(cached) return jsonify(cached)
@@ -13945,7 +13979,7 @@ def api_lxc_mount_points(vmid):
except ImportError as e: except ImportError as e:
return jsonify({"ok": False, "error": f"helper unavailable: {e}"}), 503 return jsonify({"ok": False, "error": f"helper unavailable: {e}"}), 503
try: try:
result = lxc_mount_points.get_lxc_mount_points(str(vmid)) result = lxc_mount_points.get_lxc_mount_points_static(str(vmid))
if not result.get("ok"): if not result.get("ok"):
return jsonify(result), 400 return jsonify(result), 400
_vm_cache_put(_vm_mounts_cache, vmid, result) _vm_cache_put(_vm_mounts_cache, vmid, result)
@@ -13954,6 +13988,31 @@ def api_lxc_mount_points(vmid):
return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": False, "error": str(e)}), 500
@app.route('/api/lxc/<int:vmid>/mount-points/runtime', methods=['GET'])
@require_auth
def api_lxc_mount_points_runtime(vmid):
"""Runtime half — always fresh, no cache. Returns per-target
runtime state + capacity, plus ad-hoc NFS/CIFS mounts detected
inside the running CT. Called by the client on every open of
the Mount Points tab so `df` usage and `stat` reachability are
real at click time; skips the whole prewarmer loop entirely.
Ad-hoc mounts and capacity are what the operator actually
watches (a stale NFS export shows here as `runtime_reachable
= false`), so caching them would defeat the point."""
try:
import lxc_mount_points
except ImportError as e:
return jsonify({"ok": False, "error": f"helper unavailable: {e}"}), 503
try:
result = lxc_mount_points.get_lxc_mount_points_runtime(str(vmid))
if not result.get("ok"):
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route('/api/vms/<int:vmid>/logs', methods=['GET']) @app.route('/api/vms/<int:vmid>/logs', methods=['GET'])
@require_auth @require_auth
def api_vm_logs(vmid): def api_vm_logs(vmid):
@@ -20595,7 +20654,7 @@ if __name__ == '__main__':
try: try:
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer') vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
vm_modal_thread.start() vm_modal_thread.start()
print("[ProxMenux] VM-modal prewarmer started (initial warm-up + 180s refresh)") print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
except Exception as e: except Exception as e:
print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}") print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}")
+11 -1
View File
@@ -42,7 +42,17 @@ _APPS_DIR = "/etc/proxmenux/apps"
_PCT_BIN = "/usr/sbin/pct" _PCT_BIN = "/usr/sbin/pct"
_PROBE_TIMEOUT_SEC = 15 _PROBE_TIMEOUT_SEC = 15
_GITHUB_TIMEOUT_SEC = 15 _GITHUB_TIMEOUT_SEC = 15
_UPSTREAM_CACHE_TTL_SEC = 6 * 3600 # 6 h — GitHub is polite this way # Aligned with the master LXC update cycle in
# notification_events.PollingCollector (UPDATE_CHECK_INTERVAL = 24 h).
# Previously this was 6 h — half a day out of sync with the apt/apk
# scan — so `refresh_all_apps` inside the 24 h collector would still
# hit GitHub for apps whose upstream TTL had elapsed, doubling
# checks. Unifying both to 24 h means one poll per day drives every
# update flavour (OS packages + community-scripts app upstream).
# Manual "Check" button + post-apply hook still pass force=True and
# ignore this TTL, so the user never has to wait for the timer to
# see a fresh result they explicitly asked for.
_UPSTREAM_CACHE_TTL_SEC = 24 * 3600
_VALID_METHODS = ("dpkg", "apk", "file", "binary", _VALID_METHODS = ("dpkg", "apk", "file", "binary",
"python_dist", "docker_label", "docker_exec", "python_dist", "docker_label", "docker_exec",
+187 -8
View File
@@ -539,18 +539,197 @@ def _stat_via_host(host_pid: str, ct_target: str,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_lxc_mount_points(vmid: str) -> dict[str, Any]: def get_lxc_mount_points_static(vmid: str) -> dict[str, Any]:
"""Top-level entry point used by the Flask route. """Static half of the mount-points payload — safe to cache
indefinitely because it only reads config and classifies against
PVE's storage inventory.
Returns: Returns:
- ``ok`` (bool) - ``ok`` (bool)
- ``vmid`` (str)
- ``mount_points`` list of configured mp0/mp1/... entries with
source / target / type / origin classification / host source
existence flags. No `df`, no `stat`, no ad-hoc detection.
The runtime enrichment (capacity, health, ad-hoc mounts,
runtime_mounted flag) lives in `get_lxc_mount_points_runtime`
and is fetched fresh on every modal open by the client. That
split lets the backend cache this half indefinitely (with event
invalidation on start/stop) while still giving the user real-
time capacity when they actually look."""
if not re.match(r"^\d+$", vmid):
return {"ok": False, "error": "invalid vmid"}
config_entries = _read_lxc_config(vmid)
pve_storages = _list_pve_storages()
out: list[dict[str, Any]] = []
for entry in config_entries:
source = entry.get("source", "")
target = entry.get("target", "")
cls = _classify(source, pve_storages)
host_src = _host_source_state(source)
out.append({
"mp_index": entry.get("mp_index", ""),
"source": source,
"target": target,
"type": cls["type"],
"origin_storage": cls.get("origin_storage", ""),
"origin_storage_type": cls.get("origin_storage_type", ""),
"origin_label": cls.get("origin_label", source),
"config_options": entry.get("config_options", {}),
"config_flags": entry.get("config_flags", []),
"host_source_exists": host_src["exists"],
"host_source_is_mountpoint": host_src["is_mountpoint"],
})
return {
"ok": True,
"vmid": vmid,
"mount_points": out,
}
def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
"""Runtime half — always fresh, no cache. Fetched by the client
every time the Mount Points tab is opened so the operator sees
live capacity + reachability, plus any ad-hoc NFS/CIFS mounts
the container itself has made since the last static snapshot.
Returns:
- ``ok`` (bool)
- ``vmid`` (str)
- ``running`` (bool) - ``running`` (bool)
- ``mount_points`` list of configured mp0/mp1/... entries - ``runtime`` dict keyed by target, containing runtime state
- ``ad_hoc`` list of NFS/CIFS/SMB mounts found inside the running + capacity per configured mount point
CT that aren't backed by an mp config line - ``ad_hoc`` list of NFS/CIFS/SMB mounts done inside the CT
""" that aren't backed by an mp config line
# Validate vmid format — the value comes from a URL parameter, so
# we keep it strict to avoid path-traversal weirdness. The client merges `runtime[target]` onto the matching card from
the static payload; ad-hoc mounts render as their own cards
under a "Mounted inside container" divider. If the CT is down
or the client had no static payload for a target, the tab still
renders whatever runtime info is available (never blanks)."""
if not re.match(r"^\d+$", vmid):
return {"ok": False, "error": "invalid vmid"}
config_entries = _read_lxc_config(vmid)
pve_storages = _list_pve_storages()
running, host_pid = _ct_status(vmid)
rt_mounts = _read_ct_proc_mounts(host_pid) if running else []
# Same parallelisation as the pre-split path: `df`/`stat` per
# mount point are I/O-bound. Serialised, a CT with 5+ binds
# tripped Caddy's 3s reverse-proxy timeout.
from concurrent.futures import ThreadPoolExecutor
rt_by_target: dict[str, dict[str, Any]] = {m["rt_target"]: m for m in rt_mounts}
runtime_by_target: dict[str, dict[str, Any]] = {}
matched_targets: set[str] = set()
def _gather_one(entry):
src = entry.get("source", "")
tgt = entry.get("target", "")
classification = _classify(src, pve_storages)
capacity = _capacity_for(
src, classification, pve_storages,
config_options=entry.get("config_options", {}),
host_pid=host_pid if running else "",
target=tgt,
)
live_target = bool(running and tgt and tgt in rt_by_target)
health = _stat_via_host(host_pid, tgt) if live_target else None
return entry, capacity, live_target, health
if config_entries:
max_workers = max(2, min(8, len(config_entries)))
with ThreadPoolExecutor(max_workers=max_workers) as pool:
gathered = list(pool.map(_gather_one, config_entries))
else:
gathered = []
for entry, cap, live_target, health in gathered:
target = entry.get("target", "")
rt_item: dict[str, Any] = {**cap}
if live_target:
rt = rt_by_target[target]
rt_item.update({
"runtime_mounted": True,
"runtime_source": rt["rt_source"],
"runtime_fstype": rt["rt_fstype"],
"runtime_options": rt["rt_options"],
"runtime_readonly": rt["rt_readonly"],
"runtime_reachable": health["reachable"],
"runtime_error": health["error"],
})
matched_targets.add(target)
elif running:
rt_item["runtime_mounted"] = False
rt_item["runtime_error"] = "configured but not mounted"
else:
rt_item["runtime_mounted"] = None # CT down
runtime_by_target[target] = rt_item
# Ad-hoc remote mounts inside the running CT — same logic and
# parallelisation as before.
ad_hoc: list[dict[str, Any]] = []
if running:
ad_hoc_candidates = [
rt for rt in rt_mounts
if rt["rt_target"] not in matched_targets
and _REMOTE_FS_RE.match(rt["rt_fstype"])
]
if ad_hoc_candidates:
max_workers = max(2, min(8, len(ad_hoc_candidates)))
with ThreadPoolExecutor(max_workers=max_workers) as pool:
def _gather_adhoc(rt):
h = _stat_via_host(host_pid, rt["rt_target"])
if h.get("reachable"):
cap = _df_via_pct_exec(vmid, rt["rt_target"])
else:
cap = {"total_bytes": None, "used_bytes": None,
"available_bytes": None}
return rt, h, cap
results = list(pool.map(_gather_adhoc, ad_hoc_candidates))
for rt, health, cap in results:
ad_hoc.append({
"mp_index": "",
"source": rt["rt_source"],
"target": rt["rt_target"],
"type": "ad_hoc",
"origin_storage": "",
"origin_storage_type": "",
"origin_label": rt["rt_source"],
"config_options": {},
"config_flags": [],
"total_bytes": cap["total_bytes"],
"used_bytes": cap["used_bytes"],
"available_bytes": cap["available_bytes"],
"runtime_mounted": True,
"runtime_source": rt["rt_source"],
"runtime_fstype": rt["rt_fstype"],
"runtime_options": rt["rt_options"],
"runtime_readonly": rt["rt_readonly"],
"runtime_reachable": health["reachable"],
"runtime_error": health["error"],
})
return {
"ok": True,
"vmid": vmid,
"running": running,
"runtime": runtime_by_target,
"ad_hoc": ad_hoc,
}
def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
"""Legacy combined entry point — kept for backwards compatibility
with any caller that still wants the pre-split shape. New code
should hit the static/runtime pair separately.
Merges the two halves so the returned dict matches what the
single-endpoint route used to return before the split."""
if not re.match(r"^\d+$", vmid): if not re.match(r"^\d+$", vmid):
return {"ok": False, "error": "invalid vmid"} return {"ok": False, "error": "invalid vmid"}
+12 -10
View File
@@ -497,7 +497,7 @@ force_apt_ipv4() {
# ========================================================== # ==========================================================
apply_network_optimizations() { apply_network_optimizations() {
local FUNC_VERSION="1.2" local FUNC_VERSION="1.1"
# description: Tune TCP buffers, somaxconn, IPv4 hardening and disable rp_filter on fw bridges (PVE 9 compatible). # description: Tune TCP buffers, somaxconn, IPv4 hardening and disable rp_filter on fw bridges (PVE 9 compatible).
msg_info "$(translate "Optimizing network settings...")" msg_info "$(translate "Optimizing network settings...")"
NECESSARY_REBOOT=1 NECESSARY_REBOOT=1
@@ -594,16 +594,14 @@ RemainAfterExit=yes
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
rm -f /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules cat > /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules <<'EOF'
cat > /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules <<'EOF'
ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwbr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwbr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwln*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwln*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwpr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwpr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
ACTION=="add", SUBSYSTEM=="net", KERNEL=="tap*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="tap*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
EOF EOF
chmod 0644 /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules chmod 0644 /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules
chown root:root /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules chown root:root /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules
systemctl daemon-reload >/dev/null 2>&1 || true systemctl daemon-reload >/dev/null 2>&1 || true
udevadm control --reload-rules >/dev/null 2>&1 || true udevadm control --reload-rules >/dev/null 2>&1 || true
@@ -682,7 +680,7 @@ EOF
install_log2ram_auto() { install_log2ram_auto() {
local FUNC_VERSION="1.3" local FUNC_VERSION="1.4"
# description: Install Log2RAM with size auto-tuned to host RAM (128M/256M/512M); SSD/M.2 detection skips on rotational disks. # description: Install Log2RAM with size auto-tuned to host RAM (128M/256M/512M); SSD/M.2 detection skips on rotational disks.
@@ -887,9 +885,13 @@ if (( USED_BYTES > EMERGENCY_BYTES )); then
if [[ -x /usr/sbin/logrotate && -f /etc/logrotate.d/proxmox-backup-api ]]; then if [[ -x /usr/sbin/logrotate && -f /etc/logrotate.d/proxmox-backup-api ]]; then
/usr/sbin/logrotate -f /etc/logrotate.d/proxmox-backup-api >/dev/null 2>&1 || true /usr/sbin/logrotate -f /etc/logrotate.d/proxmox-backup-api >/dev/null 2>&1 || true
fi fi
: > /var/log/pveproxy/access.log 2>/dev/null || true # Only truncate if the file already exists. Creating one from
: > /var/log/pveproxy/error.log 2>/dev/null || true # this cron path (running as root, default umask) would leave
: > /var/log/pveam.log 2>/dev/null || true # it as root:root 644 — pveproxy runs as www-data and would then
# fail to reopen it on the next restart, taking :8006 down.
[ -e /var/log/pveproxy/access.log ] && : > /var/log/pveproxy/access.log 2>/dev/null || true
[ -e /var/log/pveproxy/error.log ] && : > /var/log/pveproxy/error.log 2>/dev/null || true
[ -e /var/log/pveam.log ] && : > /var/log/pveam.log 2>/dev/null || true
"$L2R_BIN" write 2>/dev/null || true "$L2R_BIN" write 2>/dev/null || true
elif (( USED_BYTES > WARN_BYTES )); then elif (( USED_BYTES > WARN_BYTES )); then
SOFT_JOURNAL_MB=$(( SIZE_MiB * 30 / 100 )) SOFT_JOURNAL_MB=$(( SIZE_MiB * 30 / 100 ))