mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 10:47:36 +00:00
Event-only modal cache + Settings edit gating + ES i18n polish
Modal caches now refresh on events only — the periodic prewarmer runs one-shot at startup, mount points split into static/runtime endpoints, and backups get a client 6-hour gate. Updates tab shows post-apply feedback and the script terminal no longer closes the parent modal. Settings adds edit gating on 3 cards with the 3-level contrast rule applied consistently. ES translation batch (~25 fixes) and What's New for 1.2.4.1-beta refreshed.
This commit is contained in:
@@ -741,6 +741,13 @@ export function HealthThresholds() {
|
||||
{t("settings.healthThresholds.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{/* Intentional exception to the global "edit mode contrast"
|
||||
rule: the numeric inputs already carry semantic colored
|
||||
backgrounds (red critical, amber warning, blue customised)
|
||||
which are meaningful and more graphical than a plain form.
|
||||
A sunken selector would either erase those tints or force
|
||||
us to `!important` every one — cleaner to keep this card
|
||||
untouched in edit mode. */}
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
|
||||
@@ -795,7 +795,7 @@ function KeyfileActionsBar({
|
||||
|
||||
{/* Current status — icon + colour by state, no truncated fp. */}
|
||||
{escrowMode !== undefined && (
|
||||
<div className="flex items-center gap-2 text-xs bg-background/40 border border-white/10 rounded px-2.5 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs bg-card border border-white/10 rounded px-2.5 py-1.5">
|
||||
<span className="font-medium text-foreground">{t("backup.keyfileManagement.uploadToPbs")}</span>
|
||||
{currentIsFull ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-400 font-medium">
|
||||
@@ -1122,7 +1122,7 @@ export function HostBackup() {
|
||||
className={`text-[10px] uppercase tracking-wide ${
|
||||
j.profile_mode === "custom"
|
||||
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
|
||||
: "border-border text-muted-foreground bg-background/40"
|
||||
: "border-border text-muted-foreground bg-card"
|
||||
}`}
|
||||
title={
|
||||
j.profile_mode === "custom"
|
||||
@@ -1322,7 +1322,7 @@ export function HostBackup() {
|
||||
key={`${u.source}:${u.display_id}`}
|
||||
type="button"
|
||||
onClick={() => setInspectingArchive(u)}
|
||||
className="w-full text-left flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-background/40 hover:bg-white/5 hover:border-blue-500/40 transition-colors group"
|
||||
className="w-full text-left flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 hover:border-blue-500/40 transition-colors group"
|
||||
title={t("backup.archives.inspectTitle")}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1986,7 +1986,7 @@ function InspectModal({
|
||||
rows mixed in only when they carry data. Backend +
|
||||
encryption badges live here (instead of the header,
|
||||
where they used to overlap the close button). */}
|
||||
<section className="rounded-md border border-border bg-background/40 p-3 space-y-1 text-xs">
|
||||
<section className="rounded-md border border-border bg-card p-3 space-y-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.archives.backup")}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -2081,7 +2081,7 @@ function InspectModal({
|
||||
|
||||
{/* In-flight export task feedback (Download for PBS/Borg). */}
|
||||
{exportTask && (
|
||||
<div className="text-[11px] space-y-1 px-3 py-2 rounded-md border border-border bg-background/40">
|
||||
<div className="text-[11px] space-y-1 px-3 py-2 rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className={`h-3.5 w-3.5 ${exportTask.state === "completed" || exportTask.state === "failed" ? "" : "animate-spin"}`} />
|
||||
<span className="font-medium">{t(`backup.taskStates.${exportTask.state}`)}</span>
|
||||
@@ -2404,7 +2404,7 @@ function InspectModal({
|
||||
: t("backup.archives.deleteBorgDescription", { repo: remoteArc?.repo_name ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
||||
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -3318,7 +3318,7 @@ function CreateJobDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isEdit ? (
|
||||
@@ -3388,7 +3388,7 @@ function CreateJobDialog({
|
||||
type="button"
|
||||
onClick={() => setBackend(b)}
|
||||
className={`text-left p-3 rounded-md border transition-colors ${
|
||||
backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"
|
||||
backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"
|
||||
} hover:bg-white/5`}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
@@ -3419,7 +3419,7 @@ function CreateJobDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("new")}
|
||||
className={`w-full text-left p-3 rounded-md border transition-colors ${mode === "new" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5`}
|
||||
className={`w-full text-left p-3 rounded-md border transition-colors ${mode === "new" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5`}
|
||||
>
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
@@ -3436,7 +3436,7 @@ function CreateJobDialog({
|
||||
className={`w-full text-left p-3 rounded-md border transition-colors ${
|
||||
mode === "attach" && backend !== "borg"
|
||||
? "border-blue-500 bg-blue-500/5"
|
||||
: "border-border bg-background/40"
|
||||
: "border-border bg-card"
|
||||
} ${backend === "borg" ? "opacity-60 cursor-not-allowed" : "hover:bg-white/5"}`}
|
||||
>
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
@@ -3482,7 +3482,7 @@ function CreateJobDialog({
|
||||
key={j.id}
|
||||
type="button"
|
||||
onClick={() => setPveJobId(j.id)}
|
||||
className={`w-full text-left p-3 rounded-md border ${pveJobId === j.id ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`w-full text-left p-3 rounded-md border ${pveJobId === j.id ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span className="font-mono text-xs">{j.id}</span>
|
||||
@@ -3581,7 +3581,7 @@ function CreateJobDialog({
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-mono border transition-colors ${
|
||||
active
|
||||
? "border-blue-500 bg-blue-500/10 text-blue-400"
|
||||
: "border-border bg-background/40 text-muted-foreground hover:bg-white/5"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{t(`backup.weekdays.short.${d.toLowerCase()}`)}
|
||||
@@ -3653,7 +3653,7 @@ function CreateJobDialog({
|
||||
)}
|
||||
|
||||
{/* Live preview from the backend */}
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-1 text-xs">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-1 text-xs">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.schedule.preview")}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-muted-foreground">{t("backup.fields.expressionLabel")}</span>
|
||||
@@ -3726,7 +3726,7 @@ function CreateJobDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMode("default")}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t("backup.profile.default")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -3736,7 +3736,7 @@ function CreateJobDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMode("custom")}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t("backup.profile.custom")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -4223,7 +4223,7 @@ function CreateJobDialog({
|
||||
[t("backup.retention.yearly"), String(keepYearly || "")],
|
||||
].filter(([, v]) => Number(v) > 0) as Array<[string, string]>
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-2 text-xs">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t("backup.jobs.summary")}</div>
|
||||
<div><span className="text-muted-foreground">{t("backup.fields.nameLabel")}</span> <span className="font-mono text-foreground">{jobId}</span></div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -4655,7 +4655,7 @@ function ManualBackupDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5 text-blue-500" />
|
||||
@@ -4693,7 +4693,7 @@ function ManualBackupDialog({
|
||||
key={b}
|
||||
type="button"
|
||||
onClick={() => setBackend(b)}
|
||||
className={`text-left p-3 rounded-md border ${backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -4712,7 +4712,7 @@ function ManualBackupDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMode("default")}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t("backup.profile.default")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -4722,7 +4722,7 @@ function ManualBackupDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMode("custom")}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t("backup.profile.custom")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -5166,7 +5166,7 @@ function ManualBackupDialog({
|
||||
)}
|
||||
|
||||
{/* Summary — mirrors the styling of the JobDetailModal. */}
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-2 text-xs">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t("backup.jobs.summary")}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-muted-foreground">{t("backup.fields.backendLabel")}</span>
|
||||
@@ -5683,7 +5683,7 @@ function DestinationsSection({
|
||||
: t("backup.destinations.removeDescriptionWithData")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
||||
{headline}
|
||||
</div>
|
||||
{(jobs.length > 0 || backups > 0) && (
|
||||
@@ -6021,7 +6021,7 @@ function ConfigureDestinationWizard({
|
||||
key={opt.type}
|
||||
type="button"
|
||||
onClick={() => setPicked(opt.type)}
|
||||
className={`text-left rounded-lg border-2 p-4 transition-colors bg-background/40 hover:bg-white/5 ${opt.accent}`}
|
||||
className={`text-left rounded-lg border-2 p-4 transition-colors bg-card hover:bg-white/5 ${opt.accent}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{opt.type === "pbs" ? <Server className="h-4 w-4" /> :
|
||||
@@ -6302,7 +6302,7 @@ function AddDestinationDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={type !== null} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
@@ -6373,7 +6373,7 @@ function AddDestinationDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBorgMode("local")}
|
||||
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "local" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "local" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="font-medium flex items-center gap-1"><HardDrive className="h-3.5 w-3.5" /> {t("backup.destinations.localUsb")}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">{t("backup.destinations.localUsbDescription")}</div>
|
||||
@@ -6381,7 +6381,7 @@ function AddDestinationDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBorgMode("ssh")}
|
||||
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "ssh" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
|
||||
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "ssh" ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`}
|
||||
>
|
||||
<div className="font-medium flex items-center gap-1"><Server className="h-3.5 w-3.5" /> {t("backup.destinations.remoteSsh")}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">{t("backup.destinations.remoteSshDescription")}</div>
|
||||
@@ -6426,7 +6426,7 @@ function AddDestinationDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{t("backup.destinations.generateNewSshKey")}</span>
|
||||
<Button
|
||||
@@ -6474,7 +6474,7 @@ function AddDestinationDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBorgEncryptionEnabled(true)}
|
||||
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${borgEncryptionEnabled ? "border-fuchsia-500 bg-fuchsia-500/5" : "border-border bg-background/40 hover:bg-white/5"}`}
|
||||
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${borgEncryptionEnabled ? "border-fuchsia-500 bg-fuchsia-500/5" : "border-border bg-card hover:bg-white/5"}`}
|
||||
>
|
||||
<div className="font-medium">{t("backup.encryption.encryptedRepokey")}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t("backup.encryption.encryptedRepokeyDescription")}</div>
|
||||
@@ -6482,7 +6482,7 @@ function AddDestinationDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBorgEncryptionEnabled(false)}
|
||||
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${!borgEncryptionEnabled ? "border-amber-500 bg-amber-500/5" : "border-border bg-background/40 hover:bg-white/5"}`}
|
||||
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${!borgEncryptionEnabled ? "border-amber-500 bg-amber-500/5" : "border-border bg-card hover:bg-white/5"}`}
|
||||
>
|
||||
<div className="font-medium">{t("backup.encryption.noEncryption")}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t("backup.encryption.noEncryptionDescription")}</div>
|
||||
@@ -6664,7 +6664,7 @@ function UsbPicker({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2">
|
||||
<div className="rounded-md border border-border bg-card p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
<HardDrive className="h-3.5 w-3.5 text-orange-400" />
|
||||
@@ -6789,7 +6789,7 @@ function UsbPicker({
|
||||
</DialogHeader>
|
||||
{formatTarget && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
|
||||
<div className="text-xs font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
||||
{formatTarget.path_or_device}
|
||||
{formatTarget.size && <span className="text-muted-foreground"> · {formatTarget.size}</span>}
|
||||
</div>
|
||||
@@ -6934,7 +6934,7 @@ function ExtraPathsSection() {
|
||||
{paths.map((p) => (
|
||||
<div
|
||||
key={p.path}
|
||||
className="flex items-center justify-between gap-3 p-2 rounded-md border border-border bg-background/40"
|
||||
className="flex items-center justify-between gap-3 p-2 rounded-md border border-border bg-card"
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span className="font-mono text-xs truncate" title={p.path}>{p.path}</span>
|
||||
@@ -7134,7 +7134,7 @@ function UsbDrivesSection() {
|
||||
return (
|
||||
<div
|
||||
key={`${d.state}-${d.path_or_device}-${d.uuid}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded-md border border-border bg-background/40"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded-md border border-border bg-card"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -7447,7 +7447,7 @@ function JobDetailModal({
|
||||
)}
|
||||
{!detail.enabled && (
|
||||
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
|
||||
{t("backup.status.disabled")}
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
@@ -7570,13 +7570,31 @@ function JobDetailModal({
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
|
||||
<FileSearch className="h-3.5 w-3.5" /> {t("backup.profile.title")}
|
||||
</h4>
|
||||
<Field
|
||||
icon={<Server className="h-3 w-3 text-green-500/80" />}
|
||||
label={t("backup.fields.mode")}
|
||||
value={detail.profile_mode || "—"}
|
||||
mono
|
||||
labelClassName="text-green-500/90"
|
||||
/>
|
||||
{/* Profile mode rendered as a Badge to match the
|
||||
jobs list card. Same color scheme: cyan for
|
||||
`custom`, muted for `default`. */}
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||
<span className="text-[10px] uppercase tracking-wider text-green-500/90 inline-flex items-center gap-1">
|
||||
<Server className="h-3 w-3 text-green-500/80" />
|
||||
{t("backup.fields.mode")}
|
||||
</span>
|
||||
{detail.profile_mode ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] uppercase tracking-wide ${
|
||||
detail.profile_mode === "custom"
|
||||
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
|
||||
: "border-border text-muted-foreground bg-card"
|
||||
}`}
|
||||
>
|
||||
{detail.profile_mode === "custom"
|
||||
? t("backup.profile.custom")
|
||||
: t("backup.profile.default")}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground font-mono">—</span>
|
||||
)}
|
||||
</div>
|
||||
{detail.paths && detail.paths.length > 0 && (
|
||||
<PathsDisplay paths={detail.paths} />
|
||||
)}
|
||||
@@ -7704,7 +7722,7 @@ function JobDetailModal({
|
||||
{t("backup.jobs.disableJobDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
|
||||
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
||||
{detail?.id}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -8056,7 +8074,7 @@ function PbsKeyfileRecoveryDialog({
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="text-xs space-y-1 px-3 py-2 rounded-md border border-border bg-background/40">
|
||||
<div className="text-xs space-y-1 px-3 py-2 rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("backup.fields.sourceHost")}:</span>
|
||||
<span className="font-mono">{selected.source_host}</span>
|
||||
@@ -8313,7 +8331,7 @@ function ArchiveContentsModal({
|
||||
{Object.entries(data.metadata_files).map(([fname, content]) => (
|
||||
<div key={fname}>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-mono">{fname}</div>
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-40 overflow-auto rounded border border-border bg-background/40 p-2 text-foreground/80">
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-40 overflow-auto rounded border border-border bg-card p-2 text-foreground/80">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -8347,7 +8365,7 @@ function ContentsSection({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-background/40 p-3 space-y-2">
|
||||
<section className="rounded-md border border-border bg-card p-3 space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Icon className={`h-3.5 w-3.5 ${iconColor || "text-muted-foreground"}`} />
|
||||
{title}
|
||||
@@ -8534,7 +8552,7 @@ function FilesTree({ files, truncated }: { files: Array<{ path: string; size: nu
|
||||
placeholder={t("backup.placeholders.filterPaths")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="max-h-72 overflow-auto rounded border border-border bg-background/40">
|
||||
<div className="max-h-72 overflow-auto rounded border border-border bg-card">
|
||||
<ul className="text-[11px] font-mono divide-y divide-border/30">
|
||||
{filtered.slice(0, 2000).map((f) => (
|
||||
<li key={f.path} className="flex items-center justify-between gap-3 px-2 py-1 hover:bg-white/5">
|
||||
@@ -8917,7 +8935,7 @@ function RestoreOptionsModal({
|
||||
{t("backup.restore.blockedPathsHint")}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-border bg-background/40 p-1 max-h-72 overflow-auto">
|
||||
<div className="rounded-md border border-border bg-card p-1 max-h-72 overflow-auto">
|
||||
<ul className="divide-y divide-border/40">
|
||||
{filteredPaths.map((p) => {
|
||||
const blocked = isPathBlocked(p)
|
||||
|
||||
@@ -176,7 +176,13 @@ export function LxcUpdateDetection() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<CardContent
|
||||
className={`space-y-5${
|
||||
editMode
|
||||
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* ── Enable/Disable ── single-line label + toggle. The description
|
||||
paragraph was removed because the CardDescription above already
|
||||
covers the behaviour; on mobile that second paragraph forced
|
||||
|
||||
@@ -1359,7 +1359,13 @@ export function NotificationSettings() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<CardContent
|
||||
className={`space-y-5${
|
||||
editMode
|
||||
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* ── Service Status ── */}
|
||||
{status && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
@@ -231,14 +231,41 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
},
|
||||
}
|
||||
|
||||
// Each feature carries an i18n key so translations live in the
|
||||
// common.json catalogs and the modal renders in the user's chosen
|
||||
// locale. `text` is the English source of truth — it's what the
|
||||
// build-i18n-messages workflow feeds to Google Translate for locales
|
||||
// that haven't been curated by hand.
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
text: "One-click host update from the Health Monitor — new Update Now button in the System Updates section runs the Proxmox update flow in an in-dashboard terminal, without leaving the browser.",
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.pageSpeed",
|
||||
text: "Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
text: "In-app Install prompt for mobile — first-time visitors on Android and iOS Safari now see a bottom-sheet with clear steps for adding the Monitor to their home screen as a PWA.",
|
||||
key: "releaseNotes.currentFeatures.appTab",
|
||||
text: "New App tab inside the VM & LXC modal — especially for LXCs. Register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships.",
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.updatesTab",
|
||||
text: "Reworked Updates tab for LXCs: apply OS packages and registered-app updates from a single button, and schedule a recurring auto-update job that checks the container's OS and its tracked app on every run.",
|
||||
},
|
||||
{
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.backupNoTimeout",
|
||||
text: "Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
|
||||
},
|
||||
{
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.vmDiskUsage",
|
||||
text: "VMs running the QEMU Guest Agent (qemu-guest-agent) now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
|
||||
},
|
||||
{
|
||||
icon: <Smartphone className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.pwaInstall",
|
||||
text: "First-time visitors on Android and iOS Safari now see an in-app install prompt with clear steps for adding the Monitor to their home screen as a PWA.",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -302,7 +329,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
>
|
||||
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
||||
<p className="text-xs md:text-sm text-foreground leading-relaxed">
|
||||
{t(index === 0 ? "releaseNotes.currentFeatures.hostUpdate" : "releaseNotes.currentFeatures.mobileInstall")}
|
||||
{t(feature.key)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -333,6 +333,13 @@ export function Settings() {
|
||||
} | null>(null)
|
||||
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
|
||||
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
|
||||
// Edit-mode gating for Network Units. Matches the pattern used
|
||||
// by Health Monitor / Notifications / LxcUpdateDetection so every
|
||||
// settings card behaves the same way: view by default, edit only
|
||||
// after the user explicitly opens the pencil.
|
||||
const [unitsEditMode, setUnitsEditMode] = useState(false)
|
||||
const [pendingUnit, setPendingUnit] = useState<"Bytes" | "Bits" | null>(null)
|
||||
const [savedUnit, setSavedUnit] = useState(false)
|
||||
// Code viewer modal state. `version` is the version the user has
|
||||
// installed (read from installed_tools.json); `availableVersion` is
|
||||
// what the on-disk script declares — they differ when an update is
|
||||
@@ -363,11 +370,23 @@ export function Settings() {
|
||||
|
||||
// Remote Storage Exclusions
|
||||
const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([])
|
||||
const [storagesEditMode, setStoragesEditMode] = useState(false)
|
||||
// Pending toggle values keyed by storage.name. A row is only in
|
||||
// the map if the user flipped it while in edit mode; on Save we
|
||||
// walk the map and push each change to the backend, on Cancel we
|
||||
// drop it.
|
||||
const [pendingStorages, setPendingStorages] = useState<Map<string, { exclude_health: boolean; exclude_notifications: boolean }>>(new Map())
|
||||
const [savingStorages, setSavingStorages] = useState(false)
|
||||
const [savedStorages, setSavedStorages] = useState(false)
|
||||
const [loadingStorages, setLoadingStorages] = useState(true)
|
||||
const [savingStorage, setSavingStorage] = useState<string | null>(null)
|
||||
|
||||
// Network Interface Exclusions
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
|
||||
const [interfacesEditMode, setInterfacesEditMode] = useState(false)
|
||||
const [pendingInterfaces, setPendingInterfaces] = useState<Map<string, { exclude_health: boolean; exclude_notifications: boolean }>>(new Map())
|
||||
const [savingInterfaces, setSavingInterfaces] = useState(false)
|
||||
const [savedInterfaces, setSavedInterfaces] = useState(false)
|
||||
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
|
||||
const [savingInterface, setSavingInterface] = useState<string | null>(null)
|
||||
|
||||
@@ -648,6 +667,21 @@ export function Settings() {
|
||||
}))
|
||||
}
|
||||
|
||||
const handleCancelUnitsEdit = () => {
|
||||
setPendingUnit(null)
|
||||
setUnitsEditMode(false)
|
||||
}
|
||||
|
||||
const handleSaveUnits = () => {
|
||||
if (pendingUnit && pendingUnit !== networkUnitSettings) {
|
||||
changeNetworkUnit(pendingUnit)
|
||||
}
|
||||
setPendingUnit(null)
|
||||
setUnitsEditMode(false)
|
||||
setSavedUnit(true)
|
||||
setTimeout(() => setSavedUnit(false), 2000)
|
||||
}
|
||||
|
||||
const getUnitsSettings = () => {
|
||||
const networkUnit = getNetworkUnit()
|
||||
setNetworkUnitSettings(networkUnit)
|
||||
@@ -790,7 +824,62 @@ export function Settings() {
|
||||
setSavingInterface(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleCancelStoragesEdit = () => {
|
||||
setPendingStorages(new Map())
|
||||
setStoragesEditMode(false)
|
||||
}
|
||||
|
||||
const handleSaveStorages = async () => {
|
||||
if (pendingStorages.size === 0) {
|
||||
setStoragesEditMode(false)
|
||||
return
|
||||
}
|
||||
setSavingStorages(true)
|
||||
try {
|
||||
// Sequential to keep the UI's per-row spinner meaningful and
|
||||
// avoid hammering the backend with a burst on cards with many
|
||||
// exclusions.
|
||||
for (const [name, value] of pendingStorages.entries()) {
|
||||
const s = remoteStorages.find(x => x.name === name)
|
||||
if (!s) continue
|
||||
await handleStorageExclusionChange(name, s.type, value.exclude_health, value.exclude_notifications)
|
||||
}
|
||||
setPendingStorages(new Map())
|
||||
setStoragesEditMode(false)
|
||||
setSavedStorages(true)
|
||||
setTimeout(() => setSavedStorages(false), 2000)
|
||||
} finally {
|
||||
setSavingStorages(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelInterfacesEdit = () => {
|
||||
setPendingInterfaces(new Map())
|
||||
setInterfacesEditMode(false)
|
||||
}
|
||||
|
||||
const handleSaveInterfaces = async () => {
|
||||
if (pendingInterfaces.size === 0) {
|
||||
setInterfacesEditMode(false)
|
||||
return
|
||||
}
|
||||
setSavingInterfaces(true)
|
||||
try {
|
||||
for (const [name, value] of pendingInterfaces.entries()) {
|
||||
const iface = networkInterfaces.find(x => x.name === name)
|
||||
if (!iface) continue
|
||||
await handleInterfaceExclusionChange(name, iface.type, value.exclude_health, value.exclude_notifications)
|
||||
}
|
||||
setPendingInterfaces(new Map())
|
||||
setInterfacesEditMode(false)
|
||||
setSavedInterfaces(true)
|
||||
setTimeout(() => setSavedInterfaces(false), 2000)
|
||||
} finally {
|
||||
setSavingInterfaces(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectValue = (hours: number, key: string): string => {
|
||||
if (hours === -1) return "-1"
|
||||
const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours))
|
||||
@@ -937,7 +1026,14 @@ export function Settings() {
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t("settings.interfaceLanguage.label")}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("settings.interfaceLanguage.fallbackNote")}</p>
|
||||
{/* Only render the note when the current locale actually has
|
||||
one. Human-curated locales (en/es/sv/sk) leave the value
|
||||
empty, so the visible line disappears entirely; the
|
||||
auto-translated locales (de/fr/it/pt) surface the
|
||||
Google Translate disclaimer. */}
|
||||
{t("settings.interfaceLanguage.fallbackNote") && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("settings.interfaceLanguage.fallbackNote")}</p>
|
||||
)}
|
||||
</div>
|
||||
<Select value={language} onValueChange={(value) => setLanguage(value as LanguageCode)}>
|
||||
<SelectTrigger className="w-full sm:w-64">
|
||||
@@ -958,13 +1054,57 @@ export function Settings() {
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5 text-green-500" />
|
||||
<CardTitle>{t("settings.networkUnits.title")}</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5 text-green-500" />
|
||||
<CardTitle>{t("settings.networkUnits.title")}</CardTitle>
|
||||
</div>
|
||||
{!loadingUnitSettings && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedUnit && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{unitsEditMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancelUnitsEdit}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSaveUnits}
|
||||
disabled={pendingUnit === null || pendingUnit === networkUnitSettings}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setUnitsEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>{t("settings.networkUnits.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={
|
||||
unitsEditMode
|
||||
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loadingUnitSettings ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-green-500 border-t-transparent rounded-full" />
|
||||
@@ -972,8 +1112,12 @@ export function Settings() {
|
||||
) : (
|
||||
<div className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">{t("settings.networkUnits.label")}</div>
|
||||
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<Select
|
||||
value={pendingUnit ?? networkUnitSettings}
|
||||
onValueChange={(v) => setPendingUnit(v as "Bytes" | "Bits")}
|
||||
disabled={!unitsEditMode}
|
||||
>
|
||||
<SelectTrigger className={`w-28 h-8 text-xs ${!unitsEditMode ? "opacity-60" : ""}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1040,7 +1184,13 @@ export function Settings() {
|
||||
{t("settings.healthMonitor.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={
|
||||
healthEditMode
|
||||
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loadingHealth ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-red-500 border-t-transparent rounded-full" />
|
||||
@@ -1274,15 +1424,64 @@ export function Settings() {
|
||||
{/* Remote Storage Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-purple-500" />
|
||||
<CardTitle>{t("settings.remoteStorage.title")}</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-purple-500" />
|
||||
<CardTitle>{t("settings.remoteStorage.title")}</CardTitle>
|
||||
</div>
|
||||
{!loadingStorages && remoteStorages.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedStorages && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{storagesEditMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancelStoragesEdit}
|
||||
disabled={savingStorages}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSaveStorages}
|
||||
disabled={savingStorages || pendingStorages.size === 0}
|
||||
>
|
||||
{savingStorages ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setStoragesEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t("settings.remoteStorage.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={
|
||||
storagesEditMode
|
||||
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loadingStorages ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
@@ -1334,43 +1533,54 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!storage.exclude_health}
|
||||
onCheckedChange={(checked) => {
|
||||
handleStorageExclusionChange(
|
||||
storage.name,
|
||||
storage.type,
|
||||
!checked,
|
||||
storage.exclude_notifications
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!storage.exclude_notifications}
|
||||
onCheckedChange={(checked) => {
|
||||
handleStorageExclusionChange(
|
||||
storage.name,
|
||||
storage.type,
|
||||
storage.exclude_health,
|
||||
!checked
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
// Resolve the visible checked state from the pending
|
||||
// map first (unsaved local change) or fall back to
|
||||
// the storage's persisted value.
|
||||
const p = pendingStorages.get(storage.name)
|
||||
const excludeHealth = p ? p.exclude_health : storage.exclude_health
|
||||
const excludeNotif = p ? p.exclude_notifications : storage.exclude_notifications
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!excludeHealth}
|
||||
disabled={!storagesEditMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setPendingStorages(m => {
|
||||
const next = new Map(m)
|
||||
next.set(storage.name, { exclude_health: !checked, exclude_notifications: excludeNotif })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!storagesEditMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!excludeNotif}
|
||||
disabled={!storagesEditMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setPendingStorages(m => {
|
||||
const next = new Map(m)
|
||||
next.set(storage.name, { exclude_health: excludeHealth, exclude_notifications: !checked })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!storagesEditMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -1393,15 +1603,64 @@ export function Settings() {
|
||||
{/* Network Interface Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>{t("settings.networkInterfaces.title")}</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>{t("settings.networkInterfaces.title")}</CardTitle>
|
||||
</div>
|
||||
{!loadingInterfaces && networkInterfaces.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedInterfaces && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{interfacesEditMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancelInterfacesEdit}
|
||||
disabled={savingInterfaces}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSaveInterfaces}
|
||||
disabled={savingInterfaces || pendingInterfaces.size === 0}
|
||||
>
|
||||
{savingInterfaces ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setInterfacesEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t("settings.networkInterfaces.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent
|
||||
className={
|
||||
interfacesEditMode
|
||||
? "bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loadingInterfaces ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
|
||||
@@ -1458,45 +1717,54 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!iface.exclude_health}
|
||||
onCheckedChange={(checked) => {
|
||||
handleInterfaceExclusionChange(
|
||||
iface.name,
|
||||
iface.type,
|
||||
!checked,
|
||||
iface.exclude_notifications
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!iface.exclude_notifications}
|
||||
onCheckedChange={(checked) => {
|
||||
handleInterfaceExclusionChange(
|
||||
iface.name,
|
||||
iface.type,
|
||||
iface.exclude_health,
|
||||
!checked
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const p = pendingInterfaces.get(iface.name)
|
||||
const excludeHealth = p ? p.exclude_health : iface.exclude_health
|
||||
const excludeNotif = p ? p.exclude_notifications : iface.exclude_notifications
|
||||
return (
|
||||
<>
|
||||
{/* Health toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!excludeHealth}
|
||||
disabled={!interfacesEditMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setPendingInterfaces(m => {
|
||||
const next = new Map(m)
|
||||
next.set(iface.name, { exclude_health: !checked, exclude_notifications: excludeNotif })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!interfacesEditMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!excludeNotif}
|
||||
disabled={!interfacesEditMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setPendingInterfaces(m => {
|
||||
const next = new Map(m)
|
||||
next.set(iface.name, { exclude_health: excludeHealth, exclude_notifications: !checked })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!interfacesEditMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Pencil, Trash2, Check, AlertTriangle } from 'lucide-react'
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Pencil, Trash2, Check, AlertTriangle, AlertCircle } from 'lucide-react'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Switch } from "./ui/switch"
|
||||
@@ -684,11 +684,30 @@ export function VirtualMachines() {
|
||||
// revalidation cheap.
|
||||
const vmModalCacheRef = useRef({
|
||||
details: new Map<number, any>(),
|
||||
backups: new Map<number, VMBackup[]>(),
|
||||
// Backups carry a `fetchedAt` millisecond timestamp alongside the
|
||||
// payload so `fetchVmBackups` can decide whether to add `?fresh=1`
|
||||
// on the wire. The backend cache is indefinite; the 6-hour gate
|
||||
// lives here on the client, matching the operator-facing rule
|
||||
// ("scheduled/cron backups can appear anywhere in the day, but a
|
||||
// 6-hour visibility lag is acceptable"). If a modal reopens
|
||||
// within 6 h of the last fetch, no re-scan on the server.
|
||||
backups: new Map<number, { backups: VMBackup[]; fetchedAt: number }>(),
|
||||
// NOTE: apps payload lives in the shared module `lxc-apps-cache`
|
||||
// (dedup between this parent and LxcAppPanel — see fetchLxcApps).
|
||||
schedule: new Map<number, any>(),
|
||||
mountPoints: new Map<number, { mount_points: LxcMountPoint[]; ad_hoc: LxcMountPoint[] }>(),
|
||||
// Only the static half of mount-points goes here. Runtime
|
||||
// (capacity/health/ad-hoc) is intentionally NOT cached — see
|
||||
// `mountPointsRuntime` state + `fetchMountPoints` for the
|
||||
// always-fresh side.
|
||||
mountPoints: new Map<number, { mount_points: LxcMountPoint[] }>(),
|
||||
// Firewall log is on-demand ONLY — do NOT seed it from the bulk
|
||||
// modal-cache endpoint or from any prewarmer. The log is a live
|
||||
// stream (new entries flow with every packet the firewall drops)
|
||||
// so cached data has no value beyond the current session; most
|
||||
// users never open the Firewall tab, so pre-scanning would waste
|
||||
// pvesh cycles for nothing. This Map fills only when
|
||||
// `fetchFirewallLog` runs (tab click or Refresh button) and
|
||||
// survives modal reopens within the same page load.
|
||||
firewall: new Map<number, any>(),
|
||||
})
|
||||
const [controlLoading, setControlLoading] = useState(false)
|
||||
@@ -702,6 +721,21 @@ export function VirtualMachines() {
|
||||
} | null>(null)
|
||||
const [confirmDestructiveTyped, setConfirmDestructiveTyped] = useState("")
|
||||
const [detailsLoading, setDetailsLoading] = useState(false)
|
||||
// Post-apply state for the Updates tab. When the script terminal
|
||||
// closes, the tab enters a "Comprobando resultado…" state until
|
||||
// a fresh /api/vms poll delivers the new update_check counts;
|
||||
// then it flashes a short success/warning banner. Prevents the
|
||||
// confusing window where stale "40 pending" numbers are still on
|
||||
// screen right after the user just ran the updater.
|
||||
// updatesRefreshing → shows a discreet spinner in the tab
|
||||
// updatesResult → { count, applied } drives the banner
|
||||
// updatesBaselineCount → snapshot of `count` at the moment the
|
||||
// apply started; the useEffect below considers the SWR poll
|
||||
// "settled" when the observed count differs from this baseline
|
||||
// (or after a 15 s safety timeout).
|
||||
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
|
||||
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
|
||||
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
const [terminalVmid, setTerminalVmid] = useState<number | null>(null)
|
||||
const [terminalVmName, setTerminalVmName] = useState<string>("")
|
||||
@@ -750,6 +784,13 @@ export function VirtualMachines() {
|
||||
const [mountPoints, setMountPoints] = useState<LxcMountPoint[]>([])
|
||||
const [adHocMounts, setAdHocMounts] = useState<LxcMountPoint[]>([])
|
||||
const [loadingMounts, setLoadingMounts] = useState(false)
|
||||
// Runtime enrichment keyed by target — fetched fresh every open
|
||||
// (never cached). `mountPoints` cards read this to fill in usage
|
||||
// bars, reachability and runtime fstype without blocking their
|
||||
// initial render. Null while the runtime fetch is in flight; the
|
||||
// static cards still render (paths, types, storage origin) and
|
||||
// reveal usage/health when the fetch resolves.
|
||||
const [mountPointsRuntime, setMountPointsRuntime] = useState<Record<string, Partial<LxcMountPoint>> | null>(null)
|
||||
|
||||
// Detect standalone mode (webapp vs browser)
|
||||
const [isStandalone, setIsStandalone] = useState(false)
|
||||
@@ -858,6 +899,48 @@ export function VirtualMachines() {
|
||||
setSelectedVM(updated)
|
||||
}, [vmData])
|
||||
|
||||
// Settle the Updates-tab "Comprobando resultado…" state as soon as
|
||||
// the /api/vms poll delivers a post-apply count that differs from
|
||||
// the baseline captured when the terminal closed. Also drops the
|
||||
// spinner after 15 s of no observed change (backend hook already
|
||||
// force-refreshed managed_installs, so a still-equal count at that
|
||||
// point means either everything was a no-op or the scan hasn't
|
||||
// finished — either way the user shouldn't keep staring at a
|
||||
// loader). Sets `updatesResult` for the transient banner: green if
|
||||
// count is now 0, amber if some packages remain.
|
||||
useEffect(() => {
|
||||
if (!updatesRefreshing) return
|
||||
if (!selectedVM) return
|
||||
const currentCount = selectedVM.update_check?.count ?? 0
|
||||
// A change from baseline (or landing at 0) means the fresh
|
||||
// post-apply snapshot is in.
|
||||
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
|
||||
const applied = Math.max(0, updatesBaselineCount - currentCount)
|
||||
setUpdatesResult({ pendingAfter: currentCount, appliedCount: applied })
|
||||
setUpdatesRefreshing(false)
|
||||
setUpdatesBaselineCount(null)
|
||||
return
|
||||
}
|
||||
// Safety timeout — never leave the spinner spinning forever.
|
||||
const safety = setTimeout(() => {
|
||||
setUpdatesResult({
|
||||
pendingAfter: currentCount,
|
||||
appliedCount: Math.max(0, (updatesBaselineCount ?? 0) - currentCount),
|
||||
})
|
||||
setUpdatesRefreshing(false)
|
||||
setUpdatesBaselineCount(null)
|
||||
}, 15000)
|
||||
return () => clearTimeout(safety)
|
||||
}, [selectedVM, updatesRefreshing, updatesBaselineCount])
|
||||
|
||||
// Auto-dismiss the post-apply banner after 6 s so it doesn't
|
||||
// clutter the tab forever.
|
||||
useEffect(() => {
|
||||
if (!updatesResult) return
|
||||
const t = setTimeout(() => setUpdatesResult(null), 6000)
|
||||
return () => clearTimeout(t)
|
||||
}, [updatesResult])
|
||||
|
||||
const handleVMClick = async (vm: VMData) => {
|
||||
setSelectedVM(vm)
|
||||
setCurrentView("main")
|
||||
@@ -880,9 +963,16 @@ export function VirtualMachines() {
|
||||
const seedBackups = cache.backups.get(vm.vmid)
|
||||
const seedMounts = cache.mountPoints.get(vm.vmid)
|
||||
setVMDetails(seedDetails ?? null)
|
||||
setVmBackups(seedBackups ?? [])
|
||||
setVmBackups(seedBackups?.backups ?? [])
|
||||
setMountPoints(seedMounts?.mount_points ?? [])
|
||||
setAdHocMounts(seedMounts?.ad_hoc ?? [])
|
||||
// Ad-hoc mounts only come from the runtime fetch — never seeded.
|
||||
// Reset to empty on each modal open; if the CT has any, they
|
||||
// appear as soon as the runtime response arrives.
|
||||
setAdHocMounts([])
|
||||
// Runtime enrichment resets too — we never carry it across opens
|
||||
// because usage/reachability go stale within seconds. Fills in
|
||||
// when `fetchMountPoints` runtime response resolves.
|
||||
setMountPointsRuntime(null)
|
||||
setDetailsLoading(!seedDetails)
|
||||
setLoadingBackups(!seedBackups)
|
||||
if (vm.type === "lxc") setLoadingMounts(!seedMounts)
|
||||
@@ -977,7 +1067,9 @@ export function VirtualMachines() {
|
||||
const cache = vmModalCacheRef.current
|
||||
for (const g of payload.guests) {
|
||||
if (g.details) cache.details.set(g.vmid, g.details)
|
||||
if (g.backups?.backups) cache.backups.set(g.vmid, g.backups.backups)
|
||||
if (g.backups?.backups) {
|
||||
cache.backups.set(g.vmid, { backups: g.backups.backups, fetchedAt: Date.now() })
|
||||
}
|
||||
if (g.type === "lxc") {
|
||||
if (g.apps) {
|
||||
// Route lxc apps through the shared module so
|
||||
@@ -988,9 +1080,11 @@ export function VirtualMachines() {
|
||||
cache.schedule.set(g.vmid, g.schedule)
|
||||
}
|
||||
if (g.mount_points?.ok) {
|
||||
// Only the static half now — ad_hoc + runtime come
|
||||
// from the always-fresh /mount-points/runtime endpoint
|
||||
// and are not seeded from the bulk payload.
|
||||
cache.mountPoints.set(g.vmid, {
|
||||
mount_points: g.mount_points.mount_points || [],
|
||||
ad_hoc: g.mount_points.ad_hoc || [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1005,27 +1099,54 @@ export function VirtualMachines() {
|
||||
}, [vmidsKey])
|
||||
|
||||
const fetchMountPoints = async (vmid: number) => {
|
||||
setLoadingMounts(true)
|
||||
// Two fetches in parallel:
|
||||
// 1) STATIC — configured mp entries + PVE classification. Backed
|
||||
// by the indefinite backend cache; cache-hit returns in ~5 ms
|
||||
// after the first load. Seeds the cards' identity + paths.
|
||||
// 2) RUNTIME — `df` capacity, `stat` reachability, ad-hoc
|
||||
// NFS/CIFS mounts done inside the CT. Never cached — always
|
||||
// hits `df`/`stat` fresh so the operator sees the live state
|
||||
// at click time. Takes 1-3s on a CT with many binds, but the
|
||||
// cards are already visible from the static payload so the
|
||||
// user perceives no lag.
|
||||
const hasSeed = mountPoints.length > 0
|
||||
if (!hasSeed) setLoadingMounts(true)
|
||||
try {
|
||||
const response = await fetchApi<{
|
||||
ok: boolean
|
||||
running: boolean
|
||||
mount_points: LxcMountPoint[]
|
||||
ad_hoc: LxcMountPoint[]
|
||||
}>(`/api/lxc/${vmid}/mount-points`)
|
||||
if (response?.ok) {
|
||||
const mp = response.mount_points || []
|
||||
const adhoc = response.ad_hoc || []
|
||||
const [staticResp, runtimeResp] = await Promise.all([
|
||||
fetchApi<{
|
||||
ok: boolean
|
||||
mount_points: LxcMountPoint[]
|
||||
}>(`/api/lxc/${vmid}/mount-points`).catch((e) => {
|
||||
console.error("Error fetching static mount points:", e)
|
||||
return null
|
||||
}),
|
||||
fetchApi<{
|
||||
ok: boolean
|
||||
running: boolean
|
||||
runtime: Record<string, Partial<LxcMountPoint>>
|
||||
ad_hoc: LxcMountPoint[]
|
||||
}>(`/api/lxc/${vmid}/mount-points/runtime`).catch((e) => {
|
||||
console.error("Error fetching runtime mount points:", e)
|
||||
return null
|
||||
}),
|
||||
])
|
||||
if (staticResp?.ok) {
|
||||
const mp = staticResp.mount_points || []
|
||||
setMountPoints(mp)
|
||||
setAdHocMounts(adhoc)
|
||||
vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp, ad_hoc: adhoc })
|
||||
} else {
|
||||
vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp })
|
||||
} else if (!hasSeed) {
|
||||
setMountPoints([])
|
||||
}
|
||||
if (runtimeResp?.ok) {
|
||||
setMountPointsRuntime(runtimeResp.runtime || {})
|
||||
setAdHocMounts(runtimeResp.ad_hoc || [])
|
||||
} else {
|
||||
setMountPointsRuntime({})
|
||||
setAdHocMounts([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching LXC mount points:", error)
|
||||
setMountPoints([])
|
||||
if (!hasSeed) setMountPoints([])
|
||||
setAdHocMounts([])
|
||||
} finally {
|
||||
setLoadingMounts(false)
|
||||
@@ -1056,18 +1177,41 @@ export function VirtualMachines() {
|
||||
}
|
||||
|
||||
const fetchVmBackups = async (vmid: number) => {
|
||||
setLoadingBackups(true)
|
||||
// Stale-while-revalidate with a 6-hour freshness gate.
|
||||
//
|
||||
// 1. If we already have backups (bulk hydration or a previous
|
||||
// open), show them IMMEDIATELY — no spinner. React diffs by
|
||||
// volid so the list never blanks; new backups slide in at
|
||||
// the top when the fresh payload arrives (sorted newest-first
|
||||
// server-side).
|
||||
// 2. If the local cache is older than 6 h, add `?fresh=1` on
|
||||
// the wire so the server bypasses its indefinite cache and
|
||||
// re-scans every storage. Otherwise the server hands back
|
||||
// the cached snapshot instantly. This is the operator's
|
||||
// accepted lag for out-of-band backups (cron / scheduled /
|
||||
// PBS retention) that don't invalidate our cache.
|
||||
// 3. Loading spinner only on the true first load.
|
||||
const GATE_MS = 6 * 60 * 60 * 1000
|
||||
const seed = vmModalCacheRef.current.backups.get(vmid)
|
||||
const isStale = !seed || (Date.now() - seed.fetchedAt) > GATE_MS
|
||||
if (!seed) setLoadingBackups(true)
|
||||
try {
|
||||
const response = await fetchApi<{ backups?: VMBackup[] }>(`/api/vms/${vmid}/backups`)
|
||||
const url = isStale
|
||||
? `/api/vms/${vmid}/backups?fresh=1`
|
||||
: `/api/vms/${vmid}/backups`
|
||||
const response = await fetchApi<{ backups?: VMBackup[] }>(url)
|
||||
if (response.backups) {
|
||||
setVmBackups(response.backups)
|
||||
vmModalCacheRef.current.backups.set(vmid, response.backups)
|
||||
vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching VM backups:", error)
|
||||
setVmBackups([])
|
||||
// Only clear the visible list if we had nothing to show
|
||||
// in the first place — a transient network hiccup must not
|
||||
// wipe the stale-but-useful view the user is looking at.
|
||||
if (!seed) setVmBackups([])
|
||||
} finally {
|
||||
setLoadingBackups(false)
|
||||
if (!seed) setLoadingBackups(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1321,7 +1465,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [applyOpen, setApplyOpen] = useState(false)
|
||||
const [applyVmid, setApplyVmid] = useState<number | null>(null)
|
||||
const [applyTarget, setApplyTarget] = useState<"os" | "app" | "both">("os")
|
||||
const [applyBackup, setApplyBackup] = useState(true)
|
||||
// Opt-in, not opt-out. Snapshot backups take time and disk, and
|
||||
// for most routine apt updates the user doesn't want to trigger
|
||||
// a vzdump — should be a deliberate choice. If a persisted schedule
|
||||
// has `backup: true` (Options card), that value overrides this
|
||||
// default when the modal opens.
|
||||
const [applyBackup, setApplyBackup] = useState(false)
|
||||
const [applyBackupStorage, setApplyBackupStorage] = useState<string>("")
|
||||
const [applyRestart, setApplyRestart] = useState(false)
|
||||
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
|
||||
@@ -1665,12 +1814,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
c.details.delete(applyVmid)
|
||||
c.schedule.delete(applyVmid)
|
||||
invalidateLxcApps(applyVmid)
|
||||
// Backend's POST /applied handler already force-refreshes the
|
||||
// managed_installs snapshot, so the next natural /api/vms poll
|
||||
// (every 2.5s via SWR refreshInterval) picks up the post-update
|
||||
// counts on its own. We deliberately avoid mutate() or explicit
|
||||
// fetch here — those trigger re-render cascades that can close
|
||||
// the parent modal.
|
||||
// Enter the "Comprobando resultado…" state on the Updates tab.
|
||||
// Baseline snapshot lets a downstream useEffect detect when the
|
||||
// SWR poll actually delivers the post-apply counts (as opposed
|
||||
// to seeing the same stale count still in flight). Was safe
|
||||
// before to skip this because the parent modal closed with the
|
||||
// terminal; now that we keep it open on purpose, the user would
|
||||
// otherwise be staring at "40 pending" right after applying.
|
||||
setUpdatesBaselineCount(selectedVM?.update_check?.count ?? 0)
|
||||
setUpdatesResult(null)
|
||||
setUpdatesRefreshing(true)
|
||||
// Force an immediate SWR revalidation instead of waiting up to
|
||||
// 2.5 s for the natural poll — the sooner the new counts land,
|
||||
// the sooner the banner appears. mutate() is safe now: the
|
||||
// parent Dialog's onOpenChange guard swallows any spurious close
|
||||
// events triggered by re-render cascades (see the Dialog guard
|
||||
// in the JSX below).
|
||||
void mutate()
|
||||
}
|
||||
|
||||
// Render the "📦 N updates / 🛡 N security" badge next to an LXC in
|
||||
@@ -2014,13 +2174,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {total}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{running} {t("vmLxc.running")}
|
||||
{t("overview.runningCount", { count: running })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-1 flex-wrap">
|
||||
{vms > 0 && (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{vms} {t("vmLxc.vms")}
|
||||
{t("overview.vmsCount", { count: vms })}
|
||||
</Badge>
|
||||
)}
|
||||
{lxc > 0 && (
|
||||
@@ -2028,7 +2188,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
{stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
||||
{stopped} {t("vmLxc.stopped")}
|
||||
{t("overview.stoppedCount", { count: stopped })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -2498,7 +2658,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
<Dialog
|
||||
open={!!selectedVM}
|
||||
onOpenChange={() => {
|
||||
onOpenChange={(open) => {
|
||||
// Radix fires `onOpenChange(false)` for backdrop clicks,
|
||||
// ESC keys AND — critically on mobile — pointer events
|
||||
// that bubbled from a nested Dialog (script terminal,
|
||||
// LXC terminal). When any of those children is on top
|
||||
// we swallow the parent-close request; closing the child
|
||||
// must not chain-close the LXC/VM modal underneath.
|
||||
// Reported after applying an update from a phone: the
|
||||
// user tapped the terminal's Close button and got kicked
|
||||
// all the way back to the guest list.
|
||||
if (open) return
|
||||
if (applyOpen || terminalOpen) return
|
||||
setSelectedVM(null)
|
||||
setVMDetails(null)
|
||||
setCurrentView("main")
|
||||
@@ -2512,11 +2683,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
>
|
||||
<DialogContent
|
||||
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
||||
}`}
|
||||
key={selectedVM?.vmid || "no-vm"}
|
||||
// Belt and braces: while a nested terminal Dialog is on
|
||||
// top, ignore backdrop clicks and ESC entirely on THIS
|
||||
// parent Dialog. onOpenChange above already guards against
|
||||
// the child-bubble path; these two guards close the
|
||||
// remaining vectors (mobile tap slop reaching the parent's
|
||||
// scrim, ESC not consumed by the child) at the DOM level.
|
||||
onInteractOutside={(e) => {
|
||||
if (applyOpen || terminalOpen) e.preventDefault()
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (applyOpen || terminalOpen) e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{currentView === "main" ? (
|
||||
<>
|
||||
@@ -3696,6 +3879,54 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{activeModalTab === "updates" && selectedVM?.type === "lxc" && (
|
||||
<div className="space-y-4" key={`updates-${selectedVM.vmid}`}>
|
||||
{/* Post-apply feedback strip.
|
||||
Refreshing: transient loader while the /api/vms
|
||||
poll delivers the new update_check counts.
|
||||
Result: green banner if 0 pending, amber if any
|
||||
packages didn't apply. Auto-dismisses after 6s
|
||||
so the tab returns to its normal look.
|
||||
The rest of the tab (branches 0/1/2 below) still
|
||||
renders during both states so the user retains the
|
||||
context — the strip sits on top as a header, it
|
||||
doesn't replace the content. */}
|
||||
{updatesRefreshing && (
|
||||
<Card className="border-blue-500/30 bg-blue-500/5">
|
||||
<CardContent className="p-3 flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-400" />
|
||||
<span className="text-blue-300">{t("vmLxc.updates.postApplyChecking")}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{!updatesRefreshing && updatesResult && updatesResult.pendingAfter === 0 && (
|
||||
<Card className="border-emerald-500/30 bg-emerald-500/5">
|
||||
<CardContent className="p-3 flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-emerald-300">
|
||||
{updatesResult.appliedCount > 0
|
||||
? t("vmLxc.updates.postApplyAllOk", { count: updatesResult.appliedCount })
|
||||
: t("vmLxc.updates.postApplyNothingPending")}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{!updatesRefreshing && updatesResult && updatesResult.pendingAfter > 0 && (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5">
|
||||
<CardContent className="p-3 flex items-start gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-amber-300 font-medium">
|
||||
{t("vmLxc.updates.postApplyPartial", { pending: updatesResult.pendingAfter })}
|
||||
</div>
|
||||
{updatesResult.appliedCount > 0 && (
|
||||
<div className="text-amber-300/80 text-xs">
|
||||
{t("vmLxc.updates.postApplyPartialSubline", { applied: updatesResult.appliedCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Branch 0 — ProxMenux-managed OCI app (Secure Gateway).
|
||||
Same state + Update button as the App tab so
|
||||
the two panels never disagree, and either
|
||||
@@ -4024,7 +4255,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.updates.installedByHelperPrefix")} <span className="text-foreground/80">{t("vmLxc.updates.helperScriptsName")}</span>
|
||||
{" "}{t("vmLxc.updates.helperUpdatesRun")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -4708,9 +4938,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{mountPoints.map((mp) => (
|
||||
<MountPointCard key={mp.mp_index || mp.target} mp={mp} />
|
||||
))}
|
||||
{/* Merge the fresh runtime enrichment onto
|
||||
each static card. If runtime isn't in yet
|
||||
(fetch still in flight), the card renders
|
||||
with paths/type/classification only and
|
||||
the usage/health fields appear when the
|
||||
runtime response resolves — no flash, no
|
||||
re-mount because the key is stable. */}
|
||||
{mountPoints.map((mp) => {
|
||||
const rt = mountPointsRuntime?.[mp.target] as Partial<LxcMountPoint> | undefined
|
||||
const merged = rt ? { ...mp, ...rt } : mp
|
||||
return <MountPointCard key={mp.mp_index || mp.target} mp={merged} />
|
||||
})}
|
||||
{adHocMounts.length > 0 && (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-muted-foreground pt-2 border-t border-border">
|
||||
|
||||
@@ -1194,8 +1194,8 @@
|
||||
"applyOsUpdate": "Betriebssystem-Update anwenden",
|
||||
"osUpToDate": "Betriebssystem auf dem neuesten Stand",
|
||||
"installedByHelperPrefix": "Installiert von",
|
||||
"helperScriptsName": "Proxmox Helper-Scripts",
|
||||
"helperUpdatesRun": "– Updates führen den Community-Scripts-Helfer aus.",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperUpdatesRun": "Installiert von Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "installiert",
|
||||
"upstreamAvailable": "Version {version} verfügbar",
|
||||
"upToDateAt": "Aktuell unter",
|
||||
@@ -1409,14 +1409,7 @@
|
||||
"alsoDetectedContainer": "Auch auf diesem Container erkannt",
|
||||
"addAnotherApplication": "Fügen Sie eine weitere Anwendung hinzu",
|
||||
"doneButton": "Erledigt",
|
||||
"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."
|
||||
"editButton": "Bearbeiten"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1428,9 +1421,9 @@
|
||||
},
|
||||
"interfaceLanguage": {
|
||||
"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",
|
||||
"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",
|
||||
"statusPartial": "teilweise",
|
||||
"statusNeedsTranslation": "Community-Übersetzung erforderlich"
|
||||
@@ -1686,8 +1679,7 @@
|
||||
"post_install_update": "ProxMenux-Optimierungsupdates verfügbar",
|
||||
"secure_gateway_update_available": "Secure Gateway-Update verfügbar",
|
||||
"nvidia_driver_update_available": "NVIDIA-Treiberupdate verfügbar",
|
||||
"coral_driver_update_available": "Update des Coral TPU-Treibers verfügbar",
|
||||
"app_update_available": "App-Update verfügbar"
|
||||
"coral_driver_update_available": "Update des Coral TPU-Treibers verfügbar"
|
||||
},
|
||||
"ui": {
|
||||
"quietHours": "Ruhige Stunden",
|
||||
@@ -2995,7 +2987,13 @@
|
||||
"dontShowAgain": "Für diese Version nicht mehr anzeigen",
|
||||
"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.",
|
||||
"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": {
|
||||
@@ -3530,7 +3528,6 @@
|
||||
"deletePbsDescription": "Dadurch wird der PBS-Snapshot aus dem Datenspeicher entfernt.",
|
||||
"deletePbsTitle": "PBS-Snapshot löschen",
|
||||
"companionFile": "Begleitdatei",
|
||||
"descriptionAfter": "",
|
||||
"descriptionBefore": "Durchsuchen Sie die gefundenen Backups und stellen Sie sie wieder her",
|
||||
"downloadTitle": "Laden Sie dieses Backup herunter",
|
||||
"emptyAfter": "Backups noch nicht.",
|
||||
|
||||
@@ -1193,8 +1193,8 @@
|
||||
"applyOsUpdate": "Apply OS update",
|
||||
"osUpToDate": "OS up to date",
|
||||
"installedByHelperPrefix": "Installed by",
|
||||
"helperScriptsName": "Proxmox Helper-Scripts",
|
||||
"helperUpdatesRun": "— updates run the community-scripts helper.",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperUpdatesRun": "Installed by Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "installed",
|
||||
"upstreamAvailable": "upstream {version} available",
|
||||
"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.",
|
||||
"helperNotUpdateable": "The community-scripts registry marks this app as not updateable.",
|
||||
"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": {
|
||||
"closePanel": "Close panel",
|
||||
@@ -1427,9 +1432,9 @@
|
||||
},
|
||||
"interfaceLanguage": {
|
||||
"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",
|
||||
"fallbackNote": "Untranslated text is shown in English until the community fills it in.",
|
||||
"fallbackNote": "",
|
||||
"statusComplete": "complete",
|
||||
"statusPartial": "partial",
|
||||
"statusNeedsTranslation": "community translation needed"
|
||||
@@ -2994,7 +2999,13 @@
|
||||
"dontShowAgain": "Don't show again for this version",
|
||||
"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.",
|
||||
"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": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"unknown": "Desconocido"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refrescar",
|
||||
"refresh": "Recargar",
|
||||
"toggleTheme": "Alternar tema",
|
||||
"openUserMenu": "Abrir menú de usuario",
|
||||
"cancel": "Cancelar",
|
||||
@@ -29,12 +29,12 @@
|
||||
"copyToClipboard": "Copiar al portapapeles"
|
||||
},
|
||||
"navigation": {
|
||||
"overview": "Descripción general",
|
||||
"overview": "General",
|
||||
"storage": "Almacenamiento",
|
||||
"network": "Red",
|
||||
"virtualMachines": "VM y LXC",
|
||||
"hardware": "Hardware",
|
||||
"backup": "Respaldo",
|
||||
"backup": "Backup",
|
||||
"terminal": "Terminal",
|
||||
"systemLogs": "Registros del sistema",
|
||||
"security": "Seguridad",
|
||||
@@ -46,10 +46,10 @@
|
||||
"menu": "Menú de navegación"
|
||||
},
|
||||
"status": {
|
||||
"healthy": "Saludable",
|
||||
"healthy": "OK",
|
||||
"warning": "Advertencia",
|
||||
"critical": "Crítico",
|
||||
"uptime": "Tiempo de actividad: {uptime}",
|
||||
"uptime": "Uptime: {uptime}",
|
||||
"node": "Nodo: {node}",
|
||||
"connectionFailed": "Error de conexión al servidor ProxMenux",
|
||||
"checkService": "Compruebe que monitor.service se esté ejecutando correctamente.",
|
||||
@@ -60,7 +60,7 @@
|
||||
"hot": "Caliente",
|
||||
"moderate": "Moderado",
|
||||
"high": "Alto",
|
||||
"stopped": "Interrumpido",
|
||||
"stopped": "Detenido",
|
||||
"active": "Activo",
|
||||
"disabled": "Desactivado",
|
||||
"inactive": "inactivo",
|
||||
@@ -96,12 +96,12 @@
|
||||
"free": "Gratis",
|
||||
"activeVmLxc": "VM y LXC activos",
|
||||
"runningCount": "{count} en ejecución",
|
||||
"vmsCount": "{count} VMs",
|
||||
"vmsCount": "{count} VM",
|
||||
"stoppedCount": "{count} detenidos",
|
||||
"temperature": "Temperatura",
|
||||
"noSensorAvailable": "No hay sensores disponibles",
|
||||
"collectingData": "Recopilando datos...",
|
||||
"storageOverview": "Descripción general del almacenamiento",
|
||||
"storageOverview": "Almacenamiento",
|
||||
"totalNodeCapacity": "Capacidad total del nodo:",
|
||||
"totalCapacity": "Capacidad total:",
|
||||
"physicalDisks": "Discos físicos:",
|
||||
@@ -114,7 +114,7 @@
|
||||
"noVmLxcStorage": "No hay almacenamiento VM/LXC configurado",
|
||||
"localStorageSystem": "Almacenamiento local (sistema)",
|
||||
"storageDataUnavailable": "Datos de almacenamiento no disponibles",
|
||||
"networkOverview": "Descripción general de la red",
|
||||
"networkOverview": "Red",
|
||||
"activeInterfaces": "Interfaces activas:",
|
||||
"received": "Recibió:",
|
||||
"sent": "Enviado:",
|
||||
@@ -122,7 +122,7 @@
|
||||
"sentShort": "Enviado",
|
||||
"networkDataUnavailable": "Datos de red no disponibles",
|
||||
"systemInformation": "Información del sistema",
|
||||
"uptime": "Tiempo de actividad:",
|
||||
"uptime": "Uptime:",
|
||||
"uptimeDuration": {
|
||||
"dayOne": "{count} día",
|
||||
"dayFew": "{count} días",
|
||||
@@ -132,7 +132,7 @@
|
||||
"kernel": "Núcleo:",
|
||||
"availableUpdates": "Actualizaciones disponibles:",
|
||||
"packages": "paquetes",
|
||||
"systemOverview": "Descripción general del sistema",
|
||||
"systemOverview": "Sistema",
|
||||
"loadAverage1m": "Carga Promedio (1m):",
|
||||
"cpuThreads": "Hilos de CPU:",
|
||||
"networkInterfaces": "Interfaces de red:",
|
||||
@@ -214,10 +214,10 @@
|
||||
"viewDetails": "Ver detalles",
|
||||
"diskDetails": "Detalles del disco: /dev/{name}",
|
||||
"physicalDisk": "Disco físico",
|
||||
"overview": "Descripción general",
|
||||
"overview": "General",
|
||||
"smart": "SMART",
|
||||
"history": "Historia",
|
||||
"schedule": "Cronograma",
|
||||
"schedule": "Programación",
|
||||
"serialNumber": "Número de serie",
|
||||
"healthStatus": "Estado de salud",
|
||||
"wearLifetime": "Desgaste y vida útil",
|
||||
@@ -226,7 +226,7 @@
|
||||
"lifeRemaining": "Vida restante",
|
||||
"realTest": "Prueba real",
|
||||
"wear": {
|
||||
"label": "Tener puesto",
|
||||
"label": "Desgaste",
|
||||
"percentageUsed": "Porcentaje utilizado",
|
||||
"mediaWearout": "Desgaste de los medios",
|
||||
"wearLevel": "Nivel de desgaste",
|
||||
@@ -270,11 +270,11 @@
|
||||
"connection_error": "Problema de conexión"
|
||||
},
|
||||
"health": {
|
||||
"healthy": "Saludable",
|
||||
"healthy": "OK",
|
||||
"warning": "Advertencia",
|
||||
"critical": "Crítico",
|
||||
"unknown": "Desconocido",
|
||||
"passed": "Aprobado",
|
||||
"passed": "Correcto",
|
||||
"online": "En línea",
|
||||
"failed": "Fallido",
|
||||
"degraded": "Degradado"
|
||||
@@ -320,7 +320,7 @@
|
||||
"loadingReport": "Cargando informe...",
|
||||
"reportLoadFailed": "No se pudieron cargar los datos del informe.",
|
||||
"statusValues": {
|
||||
"passed": "Aprobado",
|
||||
"passed": "Correcto",
|
||||
"failed": "Fallido",
|
||||
"running": "En ejecución",
|
||||
"aborted": "Abortado",
|
||||
@@ -353,7 +353,7 @@
|
||||
"executiveSummary": "Resumen ejecutivo",
|
||||
"smartStatus": "Estado SMART",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -461,11 +461,11 @@
|
||||
"warning": "Advertencia",
|
||||
"critical": "Crítico",
|
||||
"failed": "Fallido",
|
||||
"passed": "Aprobado",
|
||||
"passed": "Correcto",
|
||||
"supported": "Apoyado",
|
||||
"notSupported": "No compatible",
|
||||
"none": "Ninguno",
|
||||
"info": "Información"
|
||||
"info": "Info"
|
||||
},
|
||||
"selfTestStatus": {
|
||||
"completedWithoutError": "Completado sin errores",
|
||||
@@ -884,7 +884,7 @@
|
||||
"sleeping": "Durmiendo",
|
||||
"diskWait": "espera de disco",
|
||||
"zombie": "Zombi",
|
||||
"stopped": "Interrumpido",
|
||||
"stopped": "Detenido",
|
||||
"tracingStop": "parada de rastreo",
|
||||
"dead": "Muerto",
|
||||
"idle": "Inactivo",
|
||||
@@ -900,8 +900,8 @@
|
||||
"totalCpuAllocated": "CPU total asignada",
|
||||
"totalMemory": "Memoria Total",
|
||||
"totalDisk": "Disco total",
|
||||
"running": "correr",
|
||||
"stopped": "interrumpido",
|
||||
"running": "Activo",
|
||||
"stopped": "Detenido",
|
||||
"vms": "máquinas virtuales",
|
||||
"used": "Usado",
|
||||
"configured": "Configurado",
|
||||
@@ -912,7 +912,7 @@
|
||||
"idle": "Inactivo",
|
||||
"listTitle": "Máquinas virtuales y contenedores",
|
||||
"empty": "No se encontraron máquinas virtuales",
|
||||
"uptime": "Tiempo de actividad: {uptime}",
|
||||
"uptime": "Uptime: {uptime}",
|
||||
"cpuUsage": "Uso de CPU",
|
||||
"memory": "Memoria",
|
||||
"disk": "Disco",
|
||||
@@ -925,8 +925,8 @@
|
||||
"cpuCores": "Núcleos de CPU",
|
||||
"notes": "Notas",
|
||||
"hideNotes": "Ocultar notas",
|
||||
"lessInfo": "Menos información",
|
||||
"info": "Información",
|
||||
"lessInfo": "Menos info",
|
||||
"info": "Info",
|
||||
"ipAddresses": "Direcciones IP",
|
||||
"enterNotes": "Introduzca notas aquí...",
|
||||
"editNotes": "Editar",
|
||||
@@ -998,7 +998,7 @@
|
||||
"type": "Tipo",
|
||||
"rateLimit": "Límite de tarifa",
|
||||
"firewall": "Cortafuegos",
|
||||
"backup": "Respaldo",
|
||||
"backup": "Backup",
|
||||
"replicate": "Reproducir exactamente",
|
||||
"volume": "Volumen",
|
||||
"path": "Ruta",
|
||||
@@ -1019,7 +1019,7 @@
|
||||
"notMounted": "no montado",
|
||||
"hostDetached": "anfitrión independiente",
|
||||
"readOnly": "solo lectura",
|
||||
"stopped": "interrumpido",
|
||||
"stopped": "Detenido",
|
||||
"mounted": "montado"
|
||||
}
|
||||
},
|
||||
@@ -1107,7 +1107,7 @@
|
||||
"notes": "Notas",
|
||||
"variables": "Variables: {{cluster}}, {{guestname}}, {{node}}, {{vmid}}",
|
||||
"creating": "Creando...",
|
||||
"submit": "Respaldo",
|
||||
"submit": "Backup",
|
||||
"typeContainer": "recipiente",
|
||||
"typeVirtualMachine": "máquina virtual",
|
||||
"modes": {
|
||||
@@ -1123,7 +1123,7 @@
|
||||
},
|
||||
"firewall": {
|
||||
"title": "Registros de cortafuegos",
|
||||
"refresh": "Refrescar",
|
||||
"refresh": "Recargar",
|
||||
"loading": "Cargando registro del cortafuegos...",
|
||||
"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.",
|
||||
@@ -1151,7 +1151,7 @@
|
||||
"title": "Actualizaciones programadas",
|
||||
"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.",
|
||||
"chipLabel": "Cronograma",
|
||||
"chipLabel": "Programado",
|
||||
"disabledSuffix": "(desactivado)",
|
||||
"whatLabel": "Qué:",
|
||||
"targetOs": "Paquetes de sistema operativo",
|
||||
@@ -1194,8 +1194,8 @@
|
||||
"applyOsUpdate": "Aplicar actualizaciones de SO",
|
||||
"osUpToDate": "SO actualizado",
|
||||
"installedByHelperPrefix": "Instalado por",
|
||||
"helperScriptsName": "Proxmox Helper-Scripts",
|
||||
"helperUpdatesRun": "— las actualizaciones ejecutan el asistente de scripts comunitarios.",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperUpdatesRun": "Instalado por Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "instalado",
|
||||
"upstreamAvailable": "versión {version} disponible",
|
||||
"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.",
|
||||
"helperNotUpdateable": "El registro de scripts comunitarios marca esta aplicación como no actualizable.",
|
||||
"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": {
|
||||
"closePanel": "Cerrar panel",
|
||||
@@ -1428,9 +1433,9 @@
|
||||
},
|
||||
"interfaceLanguage": {
|
||||
"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",
|
||||
"fallbackNote": "El texto no traducido se muestra en inglés hasta que la comunidad lo completa.",
|
||||
"fallbackNote": "",
|
||||
"statusComplete": "completo",
|
||||
"statusPartial": "parcial",
|
||||
"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.",
|
||||
"emptyTitle": "No se detectaron interfaces de red",
|
||||
"interface": "Interfaz",
|
||||
"down": "ABAJO",
|
||||
"down": "DOWN",
|
||||
"excluded": "excluido",
|
||||
"noIp": "Sin IP",
|
||||
"healthHelp": "Cuando está APAGADA, esta interfaz no activará advertencias ni alertas críticas en Health Monitor.",
|
||||
@@ -2234,7 +2239,7 @@
|
||||
"accept": "Aceptar",
|
||||
"blockReject": "Bloquear/rechazar",
|
||||
"portsCovered": "Puertos cubiertos",
|
||||
"drop": "Gota",
|
||||
"drop": "Descartar",
|
||||
"reject": "Rechazar",
|
||||
"cluster": "Grupo",
|
||||
"host": "Anfitrión",
|
||||
@@ -2673,7 +2678,7 @@
|
||||
},
|
||||
"status": {
|
||||
"connected": "Conectado",
|
||||
"stopped": "Interrumpido",
|
||||
"stopped": "Detenido",
|
||||
"error": "Error"
|
||||
},
|
||||
"installed": {
|
||||
@@ -2877,7 +2882,7 @@
|
||||
"emergency": "Emergencia",
|
||||
"alert": "Alerta",
|
||||
"warning": "Advertencia",
|
||||
"info": "Información",
|
||||
"info": "Info",
|
||||
"notice": "Aviso",
|
||||
"success": "Éxito",
|
||||
"debug": "Depurar"
|
||||
@@ -2995,7 +3000,13 @@
|
||||
"dontShowAgain": "No volver a mostrar para esta versión",
|
||||
"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.",
|
||||
"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": {
|
||||
@@ -3026,7 +3037,7 @@
|
||||
"activeCount": "{active}/{total} Activo"
|
||||
},
|
||||
"status": {
|
||||
"healthy": "Saludable",
|
||||
"healthy": "OK",
|
||||
"warning": "Advertencia",
|
||||
"critical": "Crítico",
|
||||
"up": "Arriba",
|
||||
@@ -3058,7 +3069,7 @@
|
||||
"current": "Actual",
|
||||
"domain": "Dominio",
|
||||
"down": "Abajo",
|
||||
"drops": "Gotas",
|
||||
"drops": "DESCARTADOS",
|
||||
"dropsIn": "Gotas en",
|
||||
"dropsOut": "Se retira",
|
||||
"duplex": "Dúplex",
|
||||
@@ -3362,8 +3373,8 @@
|
||||
"reinstallViaPostInstall": "Reinstale a través de ProxMenux después de la instalación: {label}",
|
||||
"noUpdatesAvailable": "No hay actualizaciones disponibles",
|
||||
"none": "ninguno",
|
||||
"running": "correr",
|
||||
"stopped": "interrumpido",
|
||||
"running": "Activo",
|
||||
"stopped": "Detenido",
|
||||
"unused": "no usado",
|
||||
"unbound": "sin consolidar",
|
||||
"driversReady": "Controladores listos",
|
||||
@@ -3520,8 +3531,8 @@
|
||||
"archives": {
|
||||
"archive": "Archivo",
|
||||
"at": "en",
|
||||
"backup": "Respaldo",
|
||||
"backupTimeLabel": "tiempo de respaldo",
|
||||
"backup": "Backup",
|
||||
"backupTimeLabel": "Creado",
|
||||
"deleteBackendTitle": "Eliminar esta copia de seguridad",
|
||||
"deleteBorgDescription": "Esto elimina el archivo Borg del repositorio.",
|
||||
"deleteBorgTitle": "Eliminar archivo Borg",
|
||||
@@ -3543,7 +3554,7 @@
|
||||
"importKeyToRestoreTitle": "Importe el archivo de claves correspondiente antes de restaurar",
|
||||
"importKeyToViewTitle": "Importe el archivo de claves correspondiente antes de ver el contenido",
|
||||
"inspectTitle": "Inspeccionar copia de seguridad",
|
||||
"job": "Trabajo",
|
||||
"job": "Tarea",
|
||||
"loadFailed": "No se pudieron cargar las copias de seguridad",
|
||||
"packedSizeLabel": "Tamaño empaquetado",
|
||||
"remoteQueryWarning": "Las copias de seguridad remotas pueden tardar un momento en consultarse.",
|
||||
@@ -3554,7 +3565,7 @@
|
||||
},
|
||||
"backends": {
|
||||
"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",
|
||||
"local": "Local",
|
||||
"localDescription": "Carpeta en este host o en una unidad USB montada.",
|
||||
@@ -3596,10 +3607,10 @@
|
||||
},
|
||||
"deleteJob": {
|
||||
"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.",
|
||||
"description": "Se eliminarán los metadatos del temporizador y del monitor para este trabajo.",
|
||||
"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 esta tarea.",
|
||||
"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": {
|
||||
"absolutePathHelp": "Utilice una ruta absoluta en este host.",
|
||||
@@ -3624,7 +3635,7 @@
|
||||
"editTitle": "Editar destino",
|
||||
"fingerprintHelp": "Verificación opcional de la huella digital del servidor.",
|
||||
"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ó",
|
||||
"leavePassphraseBlank": "Déjelo en blanco para conservar la frase de 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.",
|
||||
"managedByPve": "Gestionado por PVE",
|
||||
"manuallyAdded": "Agregado manualmente",
|
||||
"nameCredentialsLocked": "El nombre y las credenciales 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 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 tareas utilizan este destino.",
|
||||
"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.",
|
||||
"noPbsInWizard": "Aún no se han configurado repositorios de PBS.",
|
||||
"not": "no",
|
||||
@@ -3649,7 +3660,7 @@
|
||||
"probingCapacity": "Comprobando capacidad...",
|
||||
"remoteSsh": "SSH remoto",
|
||||
"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.",
|
||||
"removeDescriptionWithData": "Esto elimina el destino del Monitor. Las copias de seguridad existentes permanecen donde están.",
|
||||
"removeDestination": "Eliminar destino",
|
||||
@@ -3672,7 +3683,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
"encryptBackups": "Cifrar copias de seguridad",
|
||||
"encryptThisBackup": "Cifre esta copia de seguridad",
|
||||
@@ -3720,7 +3731,7 @@
|
||||
"repoNameAndSnapshotRequired": "Se requieren el nombre del repositorio y la instantánea.",
|
||||
"runFailed": "Error de ejecución: {error}",
|
||||
"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": {
|
||||
"absolutePathHintAfter": "— la ruta debe existir en este host exactamente como se escribió.",
|
||||
@@ -3759,9 +3770,9 @@
|
||||
"fingerprintOptional": "Huella digital (opcional)",
|
||||
"free": "Gratis",
|
||||
"groupNameInPbs": "Nombre del grupo en PBS",
|
||||
"jobId": "ID de trabajo",
|
||||
"jobIdLabel": "ID de trabajo",
|
||||
"jobName": "Nombre del trabajo",
|
||||
"jobId": "ID de tarea",
|
||||
"jobIdLabel": "ID de tarea",
|
||||
"jobName": "Nombre de la tarea",
|
||||
"kernel": "Núcleo",
|
||||
"label": "Etiqueta",
|
||||
"localDestination": "Destino local",
|
||||
@@ -3781,7 +3792,7 @@
|
||||
"pathLabel": "Ruta",
|
||||
"pbsRepository": "repositorio de PBS",
|
||||
"profileLabel": "Perfil",
|
||||
"pveJobLabel": "trabajo PvE",
|
||||
"pveJobLabel": "tarea PvE",
|
||||
"pveStorage": "almacenamiento PVE",
|
||||
"pveVersion": "versión PvE",
|
||||
"recoveryPassphrase": "Frase de contraseña de recuperación",
|
||||
@@ -3793,8 +3804,8 @@
|
||||
"repositoryPath": "Ruta del repositorio",
|
||||
"retentionLabel": "Retención",
|
||||
"roles": "Roles",
|
||||
"schedule": "Cronograma",
|
||||
"scheduleLabel": "Cronograma",
|
||||
"schedule": "Programación",
|
||||
"scheduleLabel": "Programación",
|
||||
"serverHostOrIp": "IP/host del servidor",
|
||||
"size": "Tamaño",
|
||||
"sizeLabel": "Tamaño",
|
||||
@@ -3818,56 +3829,56 @@
|
||||
},
|
||||
"jobs": {
|
||||
"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",
|
||||
"attachToPveJob": "Adjuntar al trabajo PVE",
|
||||
"attachToPveJob": "Unirse a una tarea de Backup en PVE",
|
||||
"attachedToPveVzdump": "Adjunto a PVE vzdump",
|
||||
"backendEditHelp": "El backend no se puede cambiar una vez creado el trabajo.",
|
||||
"createJob": "crear trabajo",
|
||||
"createScheduledJob": "Crear trabajo programado",
|
||||
"backendEditHelp": "El backend no se puede cambiar una vez creado la tarea.",
|
||||
"createJob": "crear tarea",
|
||||
"createScheduledJob": "Crear tarea programado",
|
||||
"customProfileTitle": "Perfil personalizado",
|
||||
"defaultProfileTitle": "Perfil predeterminado",
|
||||
"detailDescription": "Programación, perfil, destino y última ejecución de este trabajo.",
|
||||
"disableJob": "Desactivar trabajo",
|
||||
"detailDescription": "Programación, perfil, destino y última ejecución de esta tarea.",
|
||||
"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.",
|
||||
"editScheduledJob": "Editar trabajo programado",
|
||||
"editScheduledJob": "Editar tarea programado",
|
||||
"encryptedTitle": "cifrado",
|
||||
"inheritedRetentionLabel": "Retención del trabajo PVE",
|
||||
"inheritedScheduleLabel": "Programación del trabajo PVE",
|
||||
"inheritedRetentionLabel": "Retención de la tarea PVE",
|
||||
"inheritedScheduleLabel": "Programación de la tarea PVE",
|
||||
"invalidJobName": "Utilice únicamente letras, números, guiones y guiones bajos.",
|
||||
"jobNameHelpAfter": "Sea breve y legible.",
|
||||
"jobNameHelpAnd": "y",
|
||||
"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",
|
||||
"lastRunLabel": "última ejecución",
|
||||
"liveLogSize": "en vivo · {size}",
|
||||
"loadFailed": "No se pudieron cargar trabajos",
|
||||
"loadingJob": "Cargando trabajo...",
|
||||
"loadFailed": "No se pudieron cargar tareas",
|
||||
"loadingJob": "Cargando tarea...",
|
||||
"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.",
|
||||
"neverRun": "nunca corras",
|
||||
"newScheduledJob": "Nuevo trabajo programado",
|
||||
"newScheduledJob": "Nueva tarea programada",
|
||||
"newScheduledJobDescription": "Cree una tarea de copia de seguridad del host recurrente.",
|
||||
"nextRun": "Próxima ejecución",
|
||||
"nextRunLabel": "Próxima ejecución",
|
||||
"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.",
|
||||
"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",
|
||||
"notAvailableForBorg": "no disponible para Borg",
|
||||
"openJobTitle": "Abrir detalles del trabajo",
|
||||
"openJobTitle": "Abrir detalles de la tarea",
|
||||
"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.",
|
||||
"pickParentPveJob": "Elige el trabajo PVE principal",
|
||||
"pickParentPveJob": "Elige la tarea PVE principal",
|
||||
"retentionTitle": "Retención",
|
||||
"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...",
|
||||
"scheduledTitle": "Programado",
|
||||
"standaloneScheduledJob": "Trabajo programado independiente",
|
||||
"standaloneScheduledJob": "Tarea programado independiente",
|
||||
"starting": "Iniciando...",
|
||||
"stepOf": "Paso {step} de {total}",
|
||||
"summary": "Resumen",
|
||||
@@ -3949,7 +3960,7 @@
|
||||
},
|
||||
"logs": {
|
||||
"openFull": "Abrir registro completo",
|
||||
"runLog": "Ejecutar registro",
|
||||
"runLog": "Registro",
|
||||
"tail": "cola"
|
||||
},
|
||||
"manifest": {
|
||||
@@ -3975,7 +3986,7 @@
|
||||
"manual": {
|
||||
"description": "Ejecute una copia de seguridad única del host ahora.",
|
||||
"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",
|
||||
"run": "Ejecutar",
|
||||
"runBackup": "Ejecutar copia de seguridad",
|
||||
@@ -4120,7 +4131,7 @@
|
||||
"advancedHelpBefore": "Usar",
|
||||
"advancedHelpMiddle": "y",
|
||||
"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",
|
||||
"everyDayAt": "Todos los días a las {time}",
|
||||
"everyDayAtMidnight": "Todos los días a medianoche",
|
||||
@@ -4142,7 +4153,7 @@
|
||||
"pickFrequency": "Frecuencia de selección",
|
||||
"preview": "Avance",
|
||||
"rawOnCalendar": "{value} (sistema en Calendario)",
|
||||
"title": "Cronograma",
|
||||
"title": "Programación",
|
||||
"weekdaysAt": "{days} en {time}",
|
||||
"weeklyOption": "Semanalmente"
|
||||
},
|
||||
@@ -4153,7 +4164,7 @@
|
||||
"manual": "manual",
|
||||
"manualOneShot": "manual / de una sola vez",
|
||||
"ok": "OK",
|
||||
"running": "correr",
|
||||
"running": "Activo",
|
||||
"scheduled": "programado"
|
||||
},
|
||||
"taskStates": {
|
||||
@@ -4162,7 +4173,7 @@
|
||||
"packing": "embalaje",
|
||||
"queued": "en cola",
|
||||
"restoring": "restaurando",
|
||||
"running": "correr"
|
||||
"running": "Activo"
|
||||
},
|
||||
"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",
|
||||
@@ -4185,7 +4196,7 @@
|
||||
"typeDeviceExactly": "Escriba la ruta del dispositivo EXACTAMENTE para confirmar:",
|
||||
"typeDeviceToConfirm": "Escriba la ruta del dispositivo para confirmar",
|
||||
"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",
|
||||
"uuidTitle": "uuid: {uuid}",
|
||||
"wipeAndFormat": "Limpiar y formatear"
|
||||
@@ -4217,15 +4228,15 @@
|
||||
"description": "Comprobaciones detalladas de todos los componentes del sistema.",
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
"info": "Información",
|
||||
"info": "Info",
|
||||
"warning": "Advertencia",
|
||||
"critical": "Crítico",
|
||||
"unknown": "Desconocido"
|
||||
},
|
||||
"stats": {
|
||||
"total": "Total",
|
||||
"healthy": "Saludable",
|
||||
"info": "Información",
|
||||
"healthy": "OK",
|
||||
"info": "Info",
|
||||
"warning": "Advertencia",
|
||||
"critical": "Crítico",
|
||||
"unknown": "Desconocido"
|
||||
@@ -4287,7 +4298,7 @@
|
||||
"certificateValid": "El certificado es válido",
|
||||
"clusterDetected": "Clúster detectado (corosync.conf está presente)",
|
||||
"active": "Activo",
|
||||
"up": "ARRIBA",
|
||||
"up": "UP",
|
||||
"kernelUpToDate": "Kernel/PVE está actualizado",
|
||||
"proxmoxUpToDate": "Proxmox VE está actualizado",
|
||||
"noSecurityUpdates": "No hay actualizaciones de seguridad pendientes",
|
||||
@@ -4299,7 +4310,7 @@
|
||||
"gatewayLatency": "Latencia a la puerta de enlace: {latency} ms",
|
||||
"failedLogins": "{count} intentos fallidos de inicio de sesión en 24 horas",
|
||||
"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",
|
||||
"updatedDaysAgo": "Última actualización hace {count} días",
|
||||
"storageAvailable": "{type} almacenamiento está disponible",
|
||||
|
||||
@@ -1194,8 +1194,8 @@
|
||||
"applyOsUpdate": "Appliquer la mise à jour du système d'exploitation",
|
||||
"osUpToDate": "OS à jour",
|
||||
"installedByHelperPrefix": "Installé par",
|
||||
"helperScriptsName": "Proxmox Helper-Scripts",
|
||||
"helperUpdatesRun": "- les mises à jour exécutent l'assistant de scripts de communauté.",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperUpdatesRun": "Installé par Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "installé",
|
||||
"upstreamAvailable": "version {version} disponible",
|
||||
"upToDateAt": "À jour à",
|
||||
@@ -1409,14 +1409,7 @@
|
||||
"alsoDetectedContainer": "Également détecté sur ce conteneur",
|
||||
"addAnotherApplication": "Ajouter une autre application",
|
||||
"doneButton": "Fait",
|
||||
"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."
|
||||
"editButton": "Modifier"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1428,9 +1421,9 @@
|
||||
},
|
||||
"interfaceLanguage": {
|
||||
"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",
|
||||
"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",
|
||||
"statusPartial": "partiel",
|
||||
"statusNeedsTranslation": "traduction communautaire nécessaire"
|
||||
@@ -1686,8 +1679,7 @@
|
||||
"post_install_update": "Mises à jour d'optimisation de ProxMenux disponibles",
|
||||
"secure_gateway_update_available": "Mise à jour de Secure Gateway disponible",
|
||||
"nvidia_driver_update_available": "Mise à jour du pilote NVIDIA disponible",
|
||||
"coral_driver_update_available": "Mise à jour du pilote Coral TPU disponible",
|
||||
"app_update_available": "mise à jour de l'application disponible"
|
||||
"coral_driver_update_available": "Mise à jour du pilote Coral TPU disponible"
|
||||
},
|
||||
"ui": {
|
||||
"quietHours": "Heures calmes",
|
||||
@@ -2995,7 +2987,13 @@
|
||||
"dontShowAgain": "Ne plus afficher pour cette version",
|
||||
"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.",
|
||||
"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": {
|
||||
@@ -3530,7 +3528,6 @@
|
||||
"deletePbsDescription": "Cela supprime l'instantané PBS de la banque de données.",
|
||||
"deletePbsTitle": "Supprimer l'instantané PBS",
|
||||
"companionFile": "fichier compagnon",
|
||||
"descriptionAfter": "",
|
||||
"descriptionBefore": "Parcourez et restaurez les sauvegardes trouvées dans",
|
||||
"downloadTitle": "Téléchargez cette sauvegarde",
|
||||
"emptyAfter": "sauvegardes encore.",
|
||||
|
||||
@@ -1194,8 +1194,8 @@
|
||||
"applyOsUpdate": "Applica l'aggiornamento del sistema operativo",
|
||||
"osUpToDate": "Sistema operativo aggiornato",
|
||||
"installedByHelperPrefix": "Installato da",
|
||||
"helperScriptsName": "Proxmox Helper-Scripts",
|
||||
"helperUpdatesRun": "- gli aggiornamenti eseguono l'helper degli script della comunità.",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperUpdatesRun": "Installato da Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "installato",
|
||||
"upstreamAvailable": "versione {version} disponibile",
|
||||
"upToDateAt": "Aggiornato a",
|
||||
@@ -1409,14 +1409,7 @@
|
||||
"alsoDetectedContainer": "Rilevato anche su questo contenitore",
|
||||
"addAnotherApplication": "Aggiungi un'altra applicazione",
|
||||
"doneButton": "Fatto",
|
||||
"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."
|
||||
"editButton": "Modificare"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1428,9 +1421,9 @@
|
||||
},
|
||||
"interfaceLanguage": {
|
||||
"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",
|
||||
"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",
|
||||
"statusPartial": "parziale",
|
||||
"statusNeedsTranslation": "è necessaria la traduzione della comunità"
|
||||
@@ -1686,8 +1679,7 @@
|
||||
"post_install_update": "Aggiornamenti di ottimizzazione ProxMenux disponibili",
|
||||
"secure_gateway_update_available": "Aggiornamento Secure Gateway disponibile",
|
||||
"nvidia_driver_update_available": "Aggiornamento del driver NVIDIA disponibile",
|
||||
"coral_driver_update_available": "Disponibile l'aggiornamento del driver Coral TPU",
|
||||
"app_update_available": "aggiornamento dell'app disponibile"
|
||||
"coral_driver_update_available": "Disponibile l'aggiornamento del driver Coral TPU"
|
||||
},
|
||||
"ui": {
|
||||
"quietHours": "Ore tranquille",
|
||||
@@ -2995,7 +2987,13 @@
|
||||
"dontShowAgain": "Non mostrare più per questa versione",
|
||||
"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.",
|
||||
"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": {
|
||||
@@ -3530,7 +3528,6 @@
|
||||
"deletePbsDescription": "Ciò rimuove lo snapshot PBS dall'archivio dati.",
|
||||
"deletePbsTitle": "Elimina istantanea PBS",
|
||||
"companionFile": "file compagno",
|
||||
"descriptionAfter": "",
|
||||
"descriptionBefore": "Sfoglia e ripristina i backup trovati in",
|
||||
"downloadTitle": "Scarica questo backup",
|
||||
"emptyAfter": "backup ancora.",
|
||||
|
||||
@@ -1194,8 +1194,8 @@
|
||||
"applyOsUpdate": "Aplicar atualização do sistema operacional",
|
||||
"osUpToDate": "SO atualizado",
|
||||
"installedByHelperPrefix": "Instalado por",
|
||||
"helperScriptsName": "Proxmox Helper-Scripts",
|
||||
"helperUpdatesRun": "— as atualizações executam o auxiliar de scripts da comunidade.",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperUpdatesRun": "Instalado pelo Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "instalado",
|
||||
"upstreamAvailable": "versão {version} disponível",
|
||||
"upToDateAt": "Atualizado em",
|
||||
@@ -1409,14 +1409,7 @@
|
||||
"alsoDetectedContainer": "Também detectado neste contêiner",
|
||||
"addAnotherApplication": "Adicione outro aplicativo",
|
||||
"doneButton": "Feito",
|
||||
"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."
|
||||
"editButton": "Editar"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1428,9 +1421,9 @@
|
||||
},
|
||||
"interfaceLanguage": {
|
||||
"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",
|
||||
"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",
|
||||
"statusPartial": "parcial",
|
||||
"statusNeedsTranslation": "tradução comunitária necessária"
|
||||
@@ -1686,8 +1679,7 @@
|
||||
"post_install_update": "Atualizações de otimização ProxMenux disponíveis",
|
||||
"secure_gateway_update_available": "Atualização do Secure Gateway 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",
|
||||
"app_update_available": "atualização de aplicativo disponível"
|
||||
"coral_driver_update_available": "Atualização do driver Coral TPU disponível"
|
||||
},
|
||||
"ui": {
|
||||
"quietHours": "Horas tranquilas",
|
||||
@@ -2995,7 +2987,13 @@
|
||||
"dontShowAgain": "Não mostrar novamente para esta versão",
|
||||
"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.",
|
||||
"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": {
|
||||
@@ -3530,7 +3528,6 @@
|
||||
"deletePbsDescription": "Isso remove o instantâneo do PBS do armazenamento de dados.",
|
||||
"deletePbsTitle": "Excluir instantâneo do PBS",
|
||||
"companionFile": "arquivo complementar",
|
||||
"descriptionAfter": "",
|
||||
"descriptionBefore": "Procure e restaure backups encontrados em",
|
||||
"downloadTitle": "Baixe este backup",
|
||||
"emptyAfter": "backups ainda.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
"description": "Systémový prehľad Proxmoxu",
|
||||
"description": "Proxmox System Dashboard",
|
||||
"loading": "Načítava sa...",
|
||||
"connecting": "Pripájam sa k ProxMenux Monitoru",
|
||||
"unknown": "Neznáme",
|
||||
@@ -143,7 +143,7 @@
|
||||
"year": "1 rok"
|
||||
},
|
||||
"stats": {
|
||||
"avg": "priemer",
|
||||
"avg": "avg",
|
||||
"max": "max",
|
||||
"min": "min"
|
||||
},
|
||||
@@ -839,7 +839,7 @@
|
||||
"topCount": "Prvých {shown} z {total} procesov",
|
||||
"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í",
|
||||
"averageShort": "priem."
|
||||
"averageShort": "avg"
|
||||
},
|
||||
"processInfo": {
|
||||
"titleFallback": "Proces",
|
||||
@@ -1193,8 +1193,8 @@
|
||||
"applyOsUpdate": "Aktualizovať systém",
|
||||
"osUpToDate": "Systém je aktuálny",
|
||||
"installedByHelperPrefix": "Nainštalované cez",
|
||||
"helperScriptsName": "Proxmox Helper-Scripts",
|
||||
"helperUpdatesRun": "— aktualizácie spustia pomocný skript z community-scripts.",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperUpdatesRun": "Inštalované pomocou Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "nainštalovaná verzia",
|
||||
"upstreamAvailable": "dostupná verzia {version}",
|
||||
"upToDateAt": "Aktuálna verzia",
|
||||
@@ -1408,14 +1408,7 @@
|
||||
"alsoDetectedContainer": "Ďalšie aplikácie nájdené v kontajneri",
|
||||
"addAnotherApplication": "Pridať ďalšiu aplikáciu",
|
||||
"doneButton": "Hotovo",
|
||||
"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."
|
||||
"editButton": "Upraviť"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1427,9 +1420,9 @@
|
||||
},
|
||||
"interfaceLanguage": {
|
||||
"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",
|
||||
"fallbackNote": "Nepreložené texty zostanú po anglicky, kým ich komunita nedoplní.",
|
||||
"fallbackNote": "",
|
||||
"statusComplete": "hotové",
|
||||
"statusPartial": "čiastočne preložené",
|
||||
"statusNeedsTranslation": "čaká na komunitný preklad"
|
||||
@@ -1685,8 +1678,7 @@
|
||||
"post_install_update": "Dostupné aktualizácie optimalizácií ProxMenux",
|
||||
"secure_gateway_update_available": "K dispozícii je aktualizácia Secure Gateway",
|
||||
"nvidia_driver_update_available": "Dostupná aktualizácia ovládača NVIDIA",
|
||||
"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"
|
||||
"coral_driver_update_available": "Dostupná aktualizácia ovládača Coral TPU"
|
||||
},
|
||||
"ui": {
|
||||
"quietHours": "Tiché hodiny",
|
||||
@@ -2994,7 +2986,13 @@
|
||||
"dontShowAgain": "Túto verziu už nezobrazovať",
|
||||
"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.",
|
||||
"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": {
|
||||
@@ -4216,7 +4214,7 @@
|
||||
"title": "Stav systému",
|
||||
"description": "Podrobná kontrola všetkých častí systému",
|
||||
"status": {
|
||||
"ok": "V poriadku",
|
||||
"ok": "OK",
|
||||
"info": "Info",
|
||||
"warning": "Upozornenie",
|
||||
"critical": "Problém",
|
||||
@@ -4226,7 +4224,7 @@
|
||||
"total": "Spolu",
|
||||
"healthy": "V poriadku",
|
||||
"info": "Info",
|
||||
"warning": "Upozornenia",
|
||||
"warning": "Upozornenie",
|
||||
"critical": "Problémy",
|
||||
"unknown": "Neznáme"
|
||||
},
|
||||
|
||||
@@ -1194,8 +1194,8 @@
|
||||
"applyOsUpdate": "Använd OS-uppdatering",
|
||||
"osUpToDate": "OS uppdaterat",
|
||||
"installedByHelperPrefix": "Installerad av",
|
||||
"helperScriptsName": "Proxmox Helper-Scripts",
|
||||
"helperUpdatesRun": "— uppdateringar kör community-scripts helper.",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperUpdatesRun": "Installerad av Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "installerat",
|
||||
"upstreamAvailable": "version {version} tillgänglig",
|
||||
"upToDateAt": "Uppdaterad kl",
|
||||
@@ -1409,14 +1409,7 @@
|
||||
"alsoDetectedContainer": "Detekteras även på denna behållare",
|
||||
"addAnotherApplication": "Lägg till ytterligare ett program",
|
||||
"doneButton": "Gjort",
|
||||
"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."
|
||||
"editButton": "Redigera"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1428,9 +1421,9 @@
|
||||
},
|
||||
"interfaceLanguage": {
|
||||
"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",
|
||||
"fallbackNote": "Oöversatt text visas på engelska tills communityn fyller i den.",
|
||||
"fallbackNote": "",
|
||||
"statusComplete": "komplett",
|
||||
"statusPartial": "partiell",
|
||||
"statusNeedsTranslation": "gemenskapsöversättning behövs"
|
||||
@@ -1686,8 +1679,7 @@
|
||||
"post_install_update": "ProxMenux optimeringsuppdateringar tillgängliga",
|
||||
"secure_gateway_update_available": "Secure Gateway uppdatering tillgänglig",
|
||||
"nvidia_driver_update_available": "NVIDIA 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"
|
||||
"coral_driver_update_available": "Coral TPU drivrutinsuppdatering tillgänglig"
|
||||
},
|
||||
"ui": {
|
||||
"quietHours": "Tysta timmar",
|
||||
@@ -2995,7 +2987,13 @@
|
||||
"dontShowAgain": "Visa inte igen för den här versionen",
|
||||
"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.",
|
||||
"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": {
|
||||
|
||||
@@ -1616,11 +1616,30 @@ _vm_backups_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_apps_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_schedule_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
|
||||
_VM_DETAILS_TTL = 300 # config rarely changes without a user action
|
||||
_VM_BACKUPS_TTL = 120 # storage scans — a new backup is an event
|
||||
_VM_APPS_TTL = 600 # LXC apps register/unregister is rare
|
||||
_VM_SCHEDULE_TTL = 900 # persisted schedule almost never changes
|
||||
_VM_MOUNTS_TTL = 600 # mpX entries only change on manual edit
|
||||
# Effective TTL is "indefinite": these caches are refreshed only
|
||||
# by explicit event-based invalidation (`_vm_cache_invalidate` calls
|
||||
# on start/stop/reboot, add/edit/delete app, apply update, edit
|
||||
# schedule, create backup). No periodic poll — the prewarmer runs
|
||||
# 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()
|
||||
|
||||
def _vm_cache_get(cache: dict, vmid: int, ttl: int):
|
||||
@@ -11506,7 +11525,14 @@ def _node_metrics_prewarmer_loop():
|
||||
def _vm_modal_prewarmer_pass():
|
||||
"""One full sweep of every guest's modal caches. Called both
|
||||
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
|
||||
warmed = 0
|
||||
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'
|
||||
if vmid is None:
|
||||
continue
|
||||
try:
|
||||
with app.test_request_context(f'/api/vms/{vmid}'):
|
||||
_flask_g._internal_call = True
|
||||
get_vm_config(vmid)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] vm-modal prewarmer details {vmid}: {e}",
|
||||
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)
|
||||
|
||||
endpoints = [
|
||||
(_vm_details_cache, _VM_DETAILS_TTL, get_vm_config,
|
||||
f'/api/vms/{vmid}', 'details'),
|
||||
(_vm_backups_cache, _VM_BACKUPS_TTL, api_vm_backups,
|
||||
f'/api/vms/{vmid}/backups', 'backups'),
|
||||
]
|
||||
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:
|
||||
with app.test_request_context(f'/api/vms/{vmid}/apps'):
|
||||
with app.test_request_context(route):
|
||||
_flask_g._internal_call = True
|
||||
api_vm_apps_get(vmid)
|
||||
handler(vmid)
|
||||
did_work = True
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] vm-modal prewarmer apps {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}",
|
||||
print(f"[ProxMenux] vm-modal prewarmer {label} {vmid}: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
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
|
||||
|
||||
|
||||
def _vm_modal_prewarmer_loop():
|
||||
"""Keep the per-VM modal caches (details / backups / apps /
|
||||
schedule) hot from the backend, so modals always open instantly
|
||||
— even after the browser tab has been closed for a long time.
|
||||
The old React prefetcher only ran while the page was open.
|
||||
"""One-shot warmup at service startup. Populates every per-VM
|
||||
modal cache (details / backups / apps / schedule / mount points)
|
||||
exactly once, then exits — no periodic refresh loop.
|
||||
|
||||
Design (matches user's expectation of "heavy at startup, near-
|
||||
zero during runtime"):
|
||||
* One full warm-up pass right after startup so every cache is
|
||||
primed before the user opens the UI.
|
||||
* After that, a slow refresh loop at 180 s intervals. Since
|
||||
the underlying TTLs are 5-15 min, the vast majority of
|
||||
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.
|
||||
Rationale: every cache in this family is refreshed by explicit
|
||||
event invalidation (see `_vm_cache_invalidate` calls scattered
|
||||
across write endpoints). A periodic loop was double work and
|
||||
lit up the CPU on hosts with many guests. The previous 5 min
|
||||
tick meant ~500-1000 background subprocess/pvesh calls per hour
|
||||
on a 25-guest host, entirely for data that hadn't changed.
|
||||
|
||||
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
|
||||
try:
|
||||
t0 = time.time()
|
||||
n = _vm_modal_prewarmer_pass()
|
||||
print(f"[ProxMenux] VM-modal prewarmer: initial warm-up complete "
|
||||
f"({n} guests in {time.time()-t0:.1f}s)", flush=True)
|
||||
print(f"[ProxMenux] VM-modal prewarmer: warm-up complete "
|
||||
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:
|
||||
print(f"[ProxMenux] VM-modal prewarmer initial pass failed: {e}",
|
||||
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'])
|
||||
@@ -12569,11 +12586,26 @@ def api_create_backup(vmid):
|
||||
@app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
|
||||
@require_auth
|
||||
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:
|
||||
cached = _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
force_fresh = request.args.get('fresh') in ('1', 'true', 'yes')
|
||||
if not force_fresh:
|
||||
cached = _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
|
||||
backups = []
|
||||
|
||||
@@ -13928,15 +13960,17 @@ def get_vm_config(vmid):
|
||||
@app.route('/api/lxc/<int:vmid>/mount-points', methods=['GET'])
|
||||
@require_auth
|
||||
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,
|
||||
when the container is running, runtime status (mounted/not, real
|
||||
fstype, options, stale detection) and any ad-hoc NFS/CIFS/SMB the
|
||||
user mounted from inside the CT. Capacity is always populated from
|
||||
the host-side source (PVE storage or `df` of the host path) so the
|
||||
info is meaningful even on stopped containers.
|
||||
"""
|
||||
Backed by the indefinite `_vm_mounts_cache` (invalidated on
|
||||
start/stop of the guest, since config-visible fields normally
|
||||
only change through a guest reboot). The runtime endpoint is
|
||||
NEVER cached — it must reflect the live state at click time."""
|
||||
cached = _vm_cache_get(_vm_mounts_cache, vmid, _VM_MOUNTS_TTL)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
@@ -13945,7 +13979,7 @@ def api_lxc_mount_points(vmid):
|
||||
except ImportError as e:
|
||||
return jsonify({"ok": False, "error": f"helper unavailable: {e}"}), 503
|
||||
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"):
|
||||
return jsonify(result), 400
|
||||
_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
|
||||
|
||||
|
||||
@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'])
|
||||
@require_auth
|
||||
def api_vm_logs(vmid):
|
||||
@@ -20595,7 +20654,7 @@ if __name__ == '__main__':
|
||||
try:
|
||||
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
|
||||
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:
|
||||
print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}")
|
||||
|
||||
|
||||
@@ -42,7 +42,17 @@ _APPS_DIR = "/etc/proxmenux/apps"
|
||||
_PCT_BIN = "/usr/sbin/pct"
|
||||
_PROBE_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",
|
||||
"python_dist", "docker_label", "docker_exec",
|
||||
|
||||
@@ -539,18 +539,197 @@ def _stat_via_host(host_pid: str, ct_target: str,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
|
||||
"""Top-level entry point used by the Flask route.
|
||||
def get_lxc_mount_points_static(vmid: str) -> dict[str, Any]:
|
||||
"""Static half of the mount-points payload — safe to cache
|
||||
indefinitely because it only reads config and classifies against
|
||||
PVE's storage inventory.
|
||||
|
||||
Returns:
|
||||
- ``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)
|
||||
- ``mount_points`` — list of configured mp0/mp1/... entries
|
||||
- ``ad_hoc`` — list of NFS/CIFS/SMB mounts found inside the running
|
||||
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.
|
||||
- ``runtime`` — dict keyed by target, containing runtime state
|
||||
+ capacity per configured mount point
|
||||
- ``ad_hoc`` — list of NFS/CIFS/SMB mounts done inside the CT
|
||||
that aren't backed by an mp config line
|
||||
|
||||
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):
|
||||
return {"ok": False, "error": "invalid vmid"}
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ force_apt_ipv4() {
|
||||
# ==========================================================
|
||||
|
||||
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).
|
||||
msg_info "$(translate "Optimizing network settings...")"
|
||||
NECESSARY_REBOOT=1
|
||||
@@ -594,16 +594,14 @@ RemainAfterExit=yes
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
rm -f /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules
|
||||
|
||||
cat > /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules <<'EOF'
|
||||
cat > /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules <<'EOF'
|
||||
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=="fwpr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
|
||||
ACTION=="add", SUBSYSTEM=="net", KERNEL=="tap*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
|
||||
EOF
|
||||
chmod 0644 /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules
|
||||
chown root:root /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-proxmenux-fwbr-tune.rules
|
||||
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||
@@ -682,7 +680,7 @@ EOF
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -887,9 +885,13 @@ if (( USED_BYTES > EMERGENCY_BYTES )); 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
|
||||
fi
|
||||
: > /var/log/pveproxy/access.log 2>/dev/null || true
|
||||
: > /var/log/pveproxy/error.log 2>/dev/null || true
|
||||
: > /var/log/pveam.log 2>/dev/null || true
|
||||
# Only truncate if the file already exists. Creating one from
|
||||
# this cron path (running as root, default umask) would leave
|
||||
# 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
|
||||
elif (( USED_BYTES > WARN_BYTES )); then
|
||||
SOFT_JOURNAL_MB=$(( SIZE_MiB * 30 / 100 ))
|
||||
|
||||
Reference in New Issue
Block a user