Merge develop into PR #337 and resolve shared app cache conflict

This commit is contained in:
MacRimi
2026-09-05 16:51:41 +02:00
37 changed files with 5970 additions and 797 deletions
+148
View File
@@ -0,0 +1,148 @@
"use client"
import { useId, useState } from "react"
import { Info, ExternalLink, Check, Loader2, Trash2 } from "lucide-react"
import { Button } from "./ui/button"
import { Textarea } from "./ui/textarea"
import { Label } from "./ui/label"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { useT } from "@/lib/i18n/provider"
export type AppUpdateMethod = "none" | "helper" | "custom"
export function AppUpdaterEditor({ method, command, helperAvailable, helperSlug, configured, saving, changed,
onMethodChange, onCommandChange, onSave, onCancel, onRemove }: {
method: AppUpdateMethod
command: string
helperAvailable: boolean
helperSlug?: string
configured: boolean
saving: boolean
changed: boolean
onMethodChange: (method: AppUpdateMethod) => void
onCommandChange: (command: string) => void
onSave: () => void
onCancel: () => void
onRemove: () => void
}) {
const t = useT()
const commandId = useId()
const [help, setHelp] = useState<"helper" | "custom" | null>(null)
const scriptUrl = helperSlug && /^[a-z0-9][a-z0-9._-]*$/.test(helperSlug)
? `https://github.com/community-scripts/ProxmoxVE/blob/main/ct/${helperSlug}.sh` : null
// Keep the editable command readable. Download guards belong to the runner,
// which also protects this literal launcher when saved as a custom command.
const helperCommand = scriptUrl
? `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${helperSlug}.sh)"`
: null
const valid = method === "helper" ? helperAvailable && !!helperCommand : method === "custom" && !!command.trim()
const displayedCommand = method === "helper" ? (helperCommand || "") : command
const editCommand = (value: string) => {
// Edited launchers belong to the existing custom-command execution path.
// Never leave the method as "helper": saving it would discard the edits.
if (method === "helper") {
if (value.trim() === (helperCommand || "").trim()) return
onMethodChange("custom")
}
onCommandChange(value)
}
const customExamples = [
{ title: "customScriptTitle", description: "customScriptDescription", command: "/opt/my-app/update.sh" },
{ title: "customPackageTitle", description: "customPackageDescription", command: "apt-get update &&\napt-get install -y --only-upgrade my-package" },
{ title: "customBinaryTitle", description: "customBinaryDescription", command: "install -b -m 0755 /tmp/my-app.new /opt/my-app/my-app &&\nsystemctl restart my-app" },
]
const onlineExampleCommand = `script=$(mktemp) || exit 1
trap 'rm -f "$script"' EXIT
curl -fsSL 'https://example.com/my-app/update.sh' -o "$script" &&
bash "$script"`
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.updaterChoiceHint")}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={t("vmLxc.updates.updaterMethodLabel")}>
{([...(helperAvailable ? ["helper" as const] : []), "custom" as const]).map((choice) => (
<div key={choice} className="flex items-center gap-1">
<Button type="button" size="sm" variant={method === choice ? "default" : "outline"}
aria-pressed={method === choice} disabled={saving} onClick={() => onMethodChange(choice)}>
{t(`vmLxc.updates.${choice === "helper" ? "helperMethod" : "customMethod"}`)}
</Button>
<Button type="button" size="icon" variant="ghost" className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400" onClick={() => setHelp(choice)}
aria-label={t(`vmLxc.updates.${choice === "helper" ? "helperMethodHelp" : "customMethodHelp"}`)}>
<Info className="h-4 w-4" />
</Button>
</div>
))}
</div>
{helperAvailable && <p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperDetectedChoice")}</p>}
{method === "helper" && (!helperAvailable || !helperCommand) && (
<p className="text-xs text-amber-500">{t("vmLxc.updates.helperUnavailableChoice")}</p>
)}
{(method === "helper" || method === "custom") && (
<div>
<Label htmlFor={commandId} className="text-xs uppercase tracking-wider text-muted-foreground">
{t(`vmLxc.updates.${method === "helper" ? "helperCommandLabel" : "customCommandLabel"}`)}
</Label>
<Textarea id={commandId} value={displayedCommand} onChange={(event) => editCommand(event.target.value)}
placeholder={t("vmLxc.updates.customCommandPlaceholder")} disabled={saving}
className="font-mono text-xs mt-2 min-h-[100px]" maxLength={4096} />
{method === "helper" && helperCommand && (
<p className="mt-2 text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandEditHint")}</p>
)}
</div>
)}
<div className="flex items-center justify-between gap-2">
<div>{configured && (
<Button type="button" size="sm" variant="outline" className="text-red-400" disabled={saving} onClick={onRemove}>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />{t("vmLxc.updates.disableUpdater")}
</Button>
)}</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="outline" disabled={saving} onClick={onCancel}>{t("vmLxc.updates.cancelButton")}</Button>
<Button type="button" size="sm" className="bg-blue-500 hover:bg-blue-600 text-white" disabled={saving || !valid || !changed} onClick={onSave}>
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1.5" />}
{t("vmLxc.updates.saveButton")}
</Button>
</div>
</div>
<Dialog open={help !== null} onOpenChange={(open) => { if (!open) setHelp(null) }}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t(`vmLxc.updates.${help === "helper" ? "helperMethod" : "customMethod"}`)}</DialogTitle>
<DialogDescription>{t(`vmLxc.updates.${help === "helper" ? "helperMethodDescription" : "customMethodDescription"}`)}</DialogDescription>
</DialogHeader>
{help === "helper" ? (
<div className="space-y-3 text-sm">
{helperCommand && <div className="min-w-0 space-y-2">
<p className="font-medium">{t("vmLxc.updates.helperCommandLabel")}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{helperCommand}</code></pre>
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandFallback")}</p>
</div>}
<p>{t("vmLxc.updates.updaterInstructions")}</p>
<a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href="https://community-scripts.org/docs/tools/pve/update-apps" target="_blank" rel="noopener noreferrer">
{t("vmLxc.updates.helperDocumentation")}<ExternalLink className="h-4 w-4" />
</a>
{scriptUrl && <div><a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href={scriptUrl} target="_blank" rel="noopener noreferrer">
{t("vmLxc.updates.helperSource")}<ExternalLink className="h-4 w-4" />
</a></div>}
</div>
) : (
<div className="space-y-4 text-sm">
<p className="font-medium">{t("vmLxc.updates.customExamplesHeading")}</p>
{customExamples.map((example) => <div key={example.title} className="min-w-0 space-y-2">
<p className="font-medium">{t(`vmLxc.updates.${example.title}`)}</p>
<p className="text-muted-foreground">{t(`vmLxc.updates.${example.description}`)}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{example.command}</code></pre>
{example.title === "customScriptTitle" && <>
<p className="text-muted-foreground">{t("vmLxc.updates.customOnlineDescription")}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{onlineExampleCommand}</code></pre>
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.customOnlineExampleNote")}</p>
</>}
</div>)}
<p className="text-muted-foreground">{t("vmLxc.updates.customMethodExample")}</p>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
+519
View File
@@ -0,0 +1,519 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Button } from "./ui/button"
import { Badge } from "./ui/badge"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
DialogTitle,
} from "./ui/dialog"
import {
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
Loader2, MinusCircle, Play, RotateCcw, ShieldOff, XCircle,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface Finding {
check_id: string
area: string
severity: string
state: string
summary_key: string | null
summary_params: Record<string, string | number>
affected: Array<Record<string, unknown>>
evidence: string | null
remediable_by: string | null
exception?: { reason: string; accepted_by: string; accepted_at: number } | null
}
interface Run {
run_id: string
profile: string
started_at: number
finished_at: number | null
status: string
checks_total: number
}
// Findings are ordered by how much they demand attention, not by area.
// Someone triaging wants the worst thing first regardless of where it
// lives; grouping by area is the reading order of the printed document.
const STATE_RANK: Record<string, number> = {
fail: 0, warn: 1, accepted: 2, pass: 3, not_applicable: 4,
}
const STATE_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
fail: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
warn: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
accepted: { chip: "bg-muted text-muted-foreground border-border", Icon: ShieldOff },
pass: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 },
not_applicable: { chip: "bg-muted text-muted-foreground border-border", Icon: MinusCircle },
}
// An assessment older than this stops describing the current system, so
// the age is surfaced before any count rather than as a footnote.
const STALE_AFTER_DAYS = 30
export function AuditReport() {
const t = useT()
const [running, setRunning] = useState(false)
const [latest, setLatest] = useState<Run | null>(null)
const [findings, setFindings] = useState<Finding[]>([])
const [summary, setSummary] = useState<Record<string, number>>({})
const [areaFilter, setAreaFilter] = useState<string>("all")
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [showResolved, setShowResolved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState<Finding | null>(null)
const [reason, setReason] = useState("")
const [expiryDays, setExpiryDays] = useState<string>("")
const [saving, setSaving] = useState(false)
const loadRun = useCallback(async (runId: string) => {
try {
const data: any = await fetchApi(`/api/audit/runs/${runId}`)
if (data?.success) setFindings(data.findings || [])
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
const refresh = useCallback(async () => {
try {
const data: any = await fetchApi("/api/audit/status")
if (!data?.success) return
setRunning(Boolean(data.running))
setSummary(data.summary || {})
setLatest(data.latest || null)
if (data.latest?.run_id) await loadRun(data.latest.run_id)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [loadRun])
useEffect(() => { refresh() }, [refresh])
// While an assessment is in flight the page polls; once it settles the
// interval is dropped so an idle tab does not keep waking the backend.
useEffect(() => {
if (!running) return
const id = setInterval(refresh, 2000)
return () => clearInterval(id)
}, [running, refresh])
const startRun = async () => {
setError(null)
try {
const data: any = await fetchApi("/api/audit/run", {
method: "POST",
body: JSON.stringify({ profile: "full" }),
})
if (data?.success) setRunning(true)
else setError(data?.message || t("audit.errors.runFailed"))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
// Accepting or revoking changes which findings are active, so the run
// is re-read afterwards rather than patched in place: the stored
// finding is what the next report will show.
const submitAcceptance = async () => {
if (!accepting || !reason.trim()) return
setSaving(true)
try {
const body: Record<string, unknown> = {
check_id: accepting.check_id,
reason: reason.trim(),
}
if (expiryDays) body.expires_in_days = Number(expiryDays)
const data: any = await fetchApi("/api/audit/exceptions", {
method: "POST",
body: JSON.stringify(body),
})
if (!data?.success) throw new Error(data?.message || "")
setAccepting(null)
setReason("")
setExpiryDays("")
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const revokeAcceptance = async (checkId: string) => {
try {
await fetchApi(`/api/audit/exceptions/${checkId}`, { method: "DELETE" })
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const areas = useMemo(
() => Array.from(new Set(findings.map((f) => f.area))).sort(),
[findings],
)
const visible = useMemo(() => {
const quiet = new Set(["pass", "not_applicable"])
return findings
.filter((f) => areaFilter === "all" || f.area === areaFilter)
.filter((f) => showResolved || !quiet.has(f.state))
.sort((a, b) =>
(STATE_RANK[a.state] ?? 9) - (STATE_RANK[b.state] ?? 9) ||
a.check_id.localeCompare(b.check_id))
}, [findings, areaFilter, showResolved])
const acceptedCount = summary.accepted || 0
const ageDays = latest?.finished_at
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
: null
const stale = ageDays !== null && ageDays >= STALE_AFTER_DAYS
// The backend stores which sentence applies and its numbers, not the
// sentence itself, so a finding recorded under one language still reads
// correctly under another. A check that failed to evaluate has no
// per-check entry, hence the shared fallback.
const summaryOf = (f: Finding) => {
if (!f.summary_key) return ""
const params = Object.fromEntries(
Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]),
)
const key = `audit.checks.${f.check_id}.summary.${f.summary_key}`
const text = t(key, params)
return text === key ? t("audit.summaryFallback") : text
}
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
{t("audit.loading")}
</div>
)
}
return (
<div className="space-y-4">
<Card className="bg-card border-border">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-xl lg:text-2xl font-bold text-foreground">
<ClipboardCheck className="h-6 w-6" />
{t("audit.title")}
</CardTitle>
{/* Stated before any count: an assessment nobody has run, or
one run months ago, does not describe this host today. */}
{!latest ? (
<p className="text-sm text-muted-foreground">{t("audit.neverRun")}</p>
) : (
<p className={`text-sm ${stale ? "text-amber-500" : "text-muted-foreground"}`}>
{t("audit.lastRun", {
when: new Date((latest.finished_at || latest.started_at) * 1000)
.toLocaleString(),
})}
{stale && `${t("audit.stale", { days: String(ageDays) })}`}
</p>
)}
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
</div>
<Button
onClick={startRun}
disabled={running}
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
>
{running
? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t("audit.running")}</>
: <><Play className="h-4 w-4 mr-2" />{t("audit.run")}</>}
</Button>
</CardHeader>
{latest && (
<CardContent className="pt-0 space-y-3">
<div className="flex flex-wrap gap-2">
{(["fail", "warn", "accepted", "pass", "not_applicable"] as const)
.filter((s) => summary[s])
.map((s) => {
const { chip, Icon } = STATE_STYLE[s]
return (
<Badge key={s} variant="outline" className={`${chip} gap-1.5`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.states.${s}`)}
<span className="tabular-nums font-semibold">{summary[s]}</span>
</Badge>
)
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setAreaFilter("all")}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
areaFilter === "all"
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t("audit.areas.all")}
</button>
{areas.map((a) => (
<button
key={a}
type="button"
onClick={() => setAreaFilter(a)}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
areaFilter === a
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`audit.areas.${a}`)}
</button>
))}
<button
type="button"
onClick={() => setShowResolved((v) => !v)}
className="ml-auto text-sm text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{showResolved ? t("audit.hidePassing") : t("audit.showPassing")}
</button>
</div>
{/* The count of accepted risks stays visible even when the
findings themselves are filtered out of the list, so a
decision to live with something is never silently lost. */}
{acceptedCount > 0 && (
<p className="text-xs text-muted-foreground">
{t("audit.acceptedNotice", { count: String(acceptedCount) })}
</p>
)}
</CardContent>
)}
</Card>
{error && (
<p className="text-sm text-red-400 px-1">{error}</p>
)}
{latest && visible.length === 0 && (
<Card className="bg-card border-border">
<CardContent className="py-10 text-center text-muted-foreground">
{t("audit.noFindings")}
</CardContent>
</Card>
)}
<div className="space-y-2">
{visible.map((f) => {
const { chip, Icon } = STATE_STYLE[f.state] || STATE_STYLE.not_applicable
const open = expanded.has(f.check_id)
const muted = f.state === "accepted" || f.state === "not_applicable"
return (
<Card
key={f.check_id}
className={`bg-card border-border ${muted ? "opacity-70" : ""}`}
>
<button
type="button"
onClick={() => toggle(f.check_id)}
aria-expanded={open}
className="w-full text-left p-4 flex items-start gap-3 hover:bg-background/40 transition-colors rounded-lg"
>
{open
? <ChevronDown className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className={`${chip} gap-1.5 shrink-0`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.states.${f.state}`)}
</Badge>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">
{t(`audit.checks.${f.check_id}.title`)}
</span>
<Badge variant="outline" className="text-xs">
{t(`audit.areas.${f.area}`)}
</Badge>
{f.affected.length > 0 && (
<Badge variant="outline" className="text-xs tabular-nums">
{t("audit.affectedCount", { count: String(f.affected.length) })}
</Badge>
)}
</div>
{f.summary_key && (
<p className="text-sm text-muted-foreground mt-1">
{summaryOf(f)}
</p>
)}
</div>
</button>
{open && (
<CardContent className="pt-0 pl-11 space-y-4">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.why")}
</p>
<p className="text-sm text-foreground">
{t(`audit.checks.${f.check_id}.rationale`)}
</p>
</div>
{f.exception && (
<div className="rounded-md border border-border bg-background p-3">
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.acceptedRisk")}
</p>
<p className="text-sm text-foreground">{f.exception.reason}</p>
<p className="text-xs text-muted-foreground mt-1">
{f.exception.accepted_by} ·{" "}
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
</p>
</div>
)}
{f.affected.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.affected")}
</p>
<div className="flex flex-wrap gap-1.5">
{f.affected.map((o, i) => (
<Badge key={i} variant="outline" className="text-xs font-mono">
{Object.values(o).filter(Boolean).join(" · ")}
</Badge>
))}
</div>
</div>
)}
{f.evidence && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.evidence")}
</p>
{/* Wide command output scrolls inside its own box so
the page itself never scrolls sideways. */}
<pre className="text-xs font-mono bg-background border border-border rounded-md p-3 overflow-x-auto whitespace-pre">
{f.evidence}
</pre>
</div>
)}
{/* Only an active finding can be accepted, and only an
accepted one can be returned to the active set. */}
{(f.state === "fail" || f.state === "warn") && (
<Button
variant="outline"
size="sm"
onClick={() => { setAccepting(f); setReason(""); setExpiryDays("") }}
>
<ShieldOff className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.action")}
</Button>
)}
{f.state === "accepted" && (
<Button
variant="outline"
size="sm"
onClick={() => revokeAcceptance(f.check_id)}
>
<RotateCcw className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.revoke")}
</Button>
)}
</CardContent>
)}
</Card>
)
})}
</div>
<Dialog open={accepting !== null} onOpenChange={(o) => !o && setAccepting(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("audit.acceptRisk.title")}</DialogTitle>
<DialogDescription>
{accepting && t(`audit.checks.${accepting.check_id}.title`)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label htmlFor="audit-reason" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.reasonLabel")}
</label>
{/* The reason is required, not encouraged. An acceptance
without one cannot be told apart later from having
silenced the check. */}
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.reasonHelp")}
</p>
<textarea
id="audit-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("audit.acceptRisk.reasonPlaceholder")}
/>
</div>
<div>
<label htmlFor="audit-expiry" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.expiryLabel")}
</label>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.expiryHelp")}
</p>
<select
id="audit-expiry"
value={expiryDays}
onChange={(e) => setExpiryDays(e.target.value)}
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("audit.acceptRisk.expiryNever")}</option>
<option value="90">{t("audit.acceptRisk.expiry90")}</option>
<option value="180">{t("audit.acceptRisk.expiry180")}</option>
<option value="365">{t("audit.acceptRisk.expiry365")}</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAccepting(null)}>
{t("audit.acceptRisk.cancel")}
</Button>
<Button
onClick={submitAcceptance}
disabled={!reason.trim() || saving}
className="bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{t("audit.acceptRisk.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+11 -1
View File
@@ -33,7 +33,7 @@ import { Label } from "./ui/label"
import { Badge } from "./ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached } from "../lib/lxc-apps-cache"
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache"
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { useT } from "@/lib/i18n/provider"
@@ -99,6 +99,7 @@ interface AppConfig {
// The backend uses full-record replacement, so omitting these when
// editing ports/tracking would silently erase the Updates-tab setup.
update_command?: string
update_method?: "none" | "helper" | "custom"
hide_no_updater_notice?: boolean
// Per-app opt-out for the `app_update_available` notification.
// Absent / true = notify; false = silenced. Set from the bell
@@ -481,6 +482,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vmid, managed])
useEffect(() => {
if (managed) return
return subscribeLxcApps(vmid, bundle => {
setSidecar(bundle.sidecar)
setSuggestions(bundle.suggestions)
})
}, [vmid, managed])
useEffect(() => { load() }, [load])
// Live Docker Hub preview. Debounced so typing an image/regex does not
@@ -726,6 +735,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// edits one of its ports.
update_via: (existing as { update_via?: string }).update_via || "",
update_command: existing.update_command || "",
update_method: existing.update_method || (existing.update_command ? "custom" : "none"),
hide_no_updater_notice: existing.hide_no_updater_notice === true,
notifications_enabled: existing.notifications_enabled !== false,
exclude_from_badge: existing.exclude_from_badge === true,
@@ -0,0 +1,16 @@
"use client"
import { useI18n } from "../lib/i18n/provider"
export function MetricsCacheNotice({ lastChecked }: { lastChecked: number | null }) {
const { language, t } = useI18n()
if (lastChecked === null) return null
return (
<p role="status" className="text-xs text-amber-600 dark:text-amber-400 mb-2">
{t("overview.metricsCachedWarning", {
time: new Date(lastChecked * 1000).toLocaleString(language),
})}
</p>
)
}
+127 -108
View File
@@ -6,6 +6,7 @@ import { Loader2 } from 'lucide-react'
import { fetchApi } from "../lib/api-config"
import { getNetworkUnit } from "../lib/format-network"
import { useT } from "../lib/i18n/provider"
import { MetricsCacheNotice } from "./metrics-cache-notice"
interface NetworkMetricsData {
time: string
@@ -55,12 +56,13 @@ export function NetworkTrafficChart({
const [data, setData] = useState<NetworkMetricsData[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [staleAt, setStaleAt] = useState<number | null>(null)
const [isInitialLoad, setIsInitialLoad] = useState(true)
const [visibleLines, setVisibleLines] = useState({
netIn: true,
netOut: true,
})
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(
networkUnitProp || getNetworkUnit()
)
@@ -87,133 +89,147 @@ export function NetworkTrafficChart({
}, [networkUnitProp])
useEffect(() => {
let active = true
let hasData = false
let lastChecked: number | null = null
let refreshTimer: ReturnType<typeof setTimeout> | undefined
setIsInitialLoad(true)
fetchMetrics()
}, [timeframe, interfaceName, networkUnit])
useEffect(() => {
if (refreshInterval > 0) {
const interval = setInterval(() => {
fetchMetrics()
}, refreshInterval)
return () => clearInterval(interval)
}
}, [timeframe, interfaceName, refreshInterval, networkUnit]) // Added networkUnit to dependencies
const fetchMetrics = async () => {
if (isInitialLoad) {
setLoading(true)
}
setLoading(true)
setError(null)
setStaleAt(null)
setData([])
try {
const apiPath = interfaceName
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
: `/api/node/metrics?timeframe=${timeframe}`
const fetchMetrics = async () => {
try {
const apiPath = interfaceName
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
: `/api/node/metrics?timeframe=${timeframe}`
const result = await fetchApi<any>(apiPath)
const result = await fetchApi<any>(apiPath)
if (!active) return
if (!result.data || !Array.isArray(result.data)) {
throw new Error(t("network.chart.invalidDataFormat"))
}
if (result.data.length === 0) {
setData([])
setLoading(false)
return
}
const transformedData = result.data.map((item: any, index: number) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else if (timeframe === "year") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
} else {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
})
if (!result.data || !Array.isArray(result.data)) {
throw new Error(t("network.chart.invalidDataFormat"))
}
let intervalSeconds = 60
if (index > 0) {
intervalSeconds = item.time - result.data[index - 1].time
setError(null)
lastChecked = result.last_checked || Date.now() / 1000
setStaleAt(result.cache_status === "stale" ? lastChecked : null)
hasData = result.data.length > 0
if (result.data.length === 0) {
setData([])
setLoading(false)
return
}
const netInBytes = (item.netin || 0) * intervalSeconds
const netOutBytes = (item.netout || 0) * intervalSeconds
const transformedData = result.data.map((item: any, index: number) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else if (timeframe === "year") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
} else {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
})
}
let intervalSeconds = 60
if (index > 0) {
intervalSeconds = item.time - result.data[index - 1].time
}
const netInBytes = (item.netin || 0) * intervalSeconds
const netOutBytes = (item.netout || 0) * intervalSeconds
if (networkUnit === "Bits") {
return {
time: timeLabel,
timestamp: item.time,
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
}
}
if (networkUnit === "Bits") {
return {
time: timeLabel,
timestamp: item.time,
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
}
})
setData(transformedData)
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netInBytes = (item.netin || 0) * intervalSeconds
return sum + (netInBytes / 1024 / 1024 / 1024)
}, 0)
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netOutBytes = (item.netout || 0) * intervalSeconds
return sum + (netOutBytes / 1024 / 1024 / 1024)
}, 0)
if (onTotalsCalculated) {
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
}
return {
time: timeLabel,
timestamp: item.time,
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
} catch (err: any) {
if (!active) return
console.error("Error fetching network metrics:", err)
if (hasData) {
setStaleAt(lastChecked)
} else {
setError(err?.body?.code === "metrics_timeout"
? t("overview.metricsTimeout")
: err.message || t("network.chart.loadError"))
}
} finally {
if (active) {
setLoading(false)
setIsInitialLoad(false)
if (refreshInterval > 0) refreshTimer = setTimeout(fetchMetrics, refreshInterval)
}
})
setData(transformedData)
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netInBytes = (item.netin || 0) * intervalSeconds
return sum + (netInBytes / 1024 / 1024 / 1024)
}, 0)
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netOutBytes = (item.netout || 0) * intervalSeconds
return sum + (netOutBytes / 1024 / 1024 / 1024)
}, 0)
if (onTotalsCalculated) {
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
}
if (isInitialLoad) {
setIsInitialLoad(false)
}
} catch (err: any) {
console.error("Error fetching network metrics:", err)
setError(err.message || t("network.chart.loadError"))
} finally {
setLoading(false)
}
}
void fetchMetrics()
return () => {
active = false
clearTimeout(refreshTimer)
}
}, [timeframe, interfaceName, refreshInterval, networkUnit])
const tickInterval = Math.ceil(data.length / 8)
@@ -272,6 +288,8 @@ export function NetworkTrafficChart({
}
return (
<>
<MetricsCacheNotice lastChecked={staleAt} />
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -327,5 +345,6 @@ export function NetworkTrafficChart({
/>
</AreaChart>
</ResponsiveContainer>
</>
)
}
+112 -83
View File
@@ -8,6 +8,7 @@ import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
import { MetricsCacheNotice } from "./metrics-cache-notice"
const TIMEFRAME_OPTIONS = [
{ value: "hour", labelKey: "overview.timeframes.hour" },
@@ -132,6 +133,7 @@ export function NodeMetricsCharts() {
}>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<MetricsError | null>(null)
const [staleAt, setStaleAt] = useState<number | null>(null)
const isMobile = useIsMobile()
const [visibleLines, setVisibleLines] = useState({
@@ -144,98 +146,124 @@ export function NodeMetricsCharts() {
const hasMemoryFree = data.some(d => d.memoryFree > 0)
useEffect(() => {
fetchMetrics()
}, [timeframe, language])
const fetchMetrics = async () => {
let active = true
let hasData = false
let lastChecked: number | null = null
let retryTimer: ReturnType<typeof setTimeout> | undefined
setLoading(true)
setError(null)
setStaleAt(null)
try {
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
const fetchMetrics = async () => {
let retry = false
try {
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
if (!active) return
if (!result.data || !Array.isArray(result.data)) {
console.error("Invalid data format - data is not an array:", result)
throw new Error(t("overview.invalidMetricsData"))
}
if (result.data.length === 0) {
console.warn("No data points received")
setData([])
setLoading(false)
return
}
if (result.data[0]?.loadavg) {
}
const transformedData = result.data.map((item: any) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
if (!result.data || !Array.isArray(result.data)) {
console.error("Invalid data format - data is not an array:", result)
throw new Error(t("overview.invalidMetricsData"))
}
return {
time: timeLabel,
timestamp: item.time,
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
load: item.loadavg
? typeof item.loadavg === "number"
? Number(item.loadavg.toFixed(2))
: Array.isArray(item.loadavg) && item.loadavg.length > 0
? Number(item.loadavg[0].toFixed(2))
: 0
: 0,
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
}
})
setError(null)
lastChecked = result.last_checked || Date.now() / 1000
hasData = result.data.length > 0
setStaleAt(result.cache_status === "stale" ? lastChecked : null)
retry = result.cache_status === "stale"
setData(transformedData)
setPeriodStats(result.period_stats || {})
} catch (err: any) {
console.error("Error fetching node metrics:", err)
// fetchApi attaches the parsed JSON body to err.body. The metrics
// endpoint enriches 503 responses with `details` (Proxmox-side
// diagnostic) and `suggestion` (how to fix). Pull them through so
// the user sees actionable text instead of a bare "503".
const body = err?.body
setError({
headline: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.details,
suggestion: body?.suggestion,
})
} finally {
setLoading(false)
if (result.data.length === 0) {
console.warn("No data points received")
setData([])
setLoading(false)
return
}
const transformedData = result.data.map((item: any) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
}
return {
time: timeLabel,
timestamp: item.time,
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
load: item.loadavg
? typeof item.loadavg === "number"
? Number(item.loadavg.toFixed(2))
: Array.isArray(item.loadavg) && item.loadavg.length > 0
? Number(item.loadavg[0].toFixed(2))
: 0
: 0,
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
}
})
setData(transformedData)
setPeriodStats(result.period_stats || {})
} catch (err: any) {
if (!active) return
retry = true
console.error("Error fetching node metrics:", err)
if (hasData) {
setStaleAt(lastChecked)
return
}
// fetchApi attaches the parsed JSON body to err.body. The metrics
// endpoint enriches 503 responses with `details` (Proxmox-side
// diagnostic) and `suggestion` (how to fix). Pull them through so
// the user sees actionable text instead of a bare "503".
const body = err?.body
setError({
headline: body?.code === "metrics_timeout"
? t("overview.metricsTimeout")
: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.code === "metrics_timeout" ? undefined : body?.details,
suggestion: body?.suggestion,
})
} finally {
if (active) {
setLoading(false)
if (retry) retryTimer = setTimeout(fetchMetrics, 60000)
}
}
}
}
void fetchMetrics()
return () => {
active = false
clearTimeout(retryTimer)
}
}, [timeframe, language])
const tickInterval = Math.ceil(data.length / 8)
@@ -358,6 +386,7 @@ export function NodeMetricsCharts() {
return (
<div className="space-y-6">
<MetricsCacheNotice lastChecked={staleAt} />
{/* Timeframe Selector */}
<div className="flex justify-end">
<Select value={timeframe} onValueChange={setTimeframe}>
@@ -12,6 +12,7 @@ import { VirtualMachines } from "./virtual-machines"
import { AppsDashboard } from "./apps-dashboard"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { AuditReport } from "./audit-report"
import { Settings } from "./settings"
import { Security } from "./security"
import { Profile } from "./profile"
@@ -41,6 +42,7 @@ import {
Settings2,
Terminal,
ShieldCheck,
ClipboardCheck,
Info,
DatabaseBackup,
ChevronDown,
@@ -399,6 +401,7 @@ export function ProxmoxDashboard() {
case "terminal": return t("navigation.terminal")
case "logs": return t("navigation.systemLogs")
case "security": return t("navigation.security")
case "audit": return t("navigation.audit")
case "settings": return t("navigation.settings")
case "about": return t("navigation.about")
case "profile": return t("navigation.profile")
@@ -618,6 +621,7 @@ export function ProxmoxDashboard() {
const ADMIN_ITEMS = [
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
]
@@ -840,6 +844,10 @@ export function ProxmoxDashboard() {
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
<AuditReport key={`audit-${componentKey}`} />
</TabsContent>
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
<HostBackup key={`backup-${componentKey}`} />
</TabsContent>
+68 -75
View File
@@ -3,7 +3,7 @@
import type React from "react"
import { useState, useMemo, useEffect, useRef } from "react"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached } from "../lib/lxc-apps-cache"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache"
import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
@@ -22,6 +22,7 @@ import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { LxcAppPanel, ThemeAwareLogo } from "./lxc-app-panel"
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
@@ -104,6 +105,8 @@ interface LxcAppWatch {
health_path?: string | null
installed_version: string | null
latest_version: string | null
latest_published_at?: string | null
state_revision?: number
update_available: boolean | null
error: string | null
checked_at: string | null
@@ -117,6 +120,7 @@ interface LxcAppWatch {
// update method. When set, the Updates tab renders an "Apply {app}"
// button that runs `pct exec vmid -- sh -c "$update_command"`.
update_command?: string
update_method?: AppUpdateMethod
// Updates tab: per-app dismiss for the "no update method defined"
// notice. Only hides the notice — the App tab still shows purple ⬆
// when an update is available upstream.
@@ -232,13 +236,21 @@ function normalizeVmSearchValue(value: unknown): string {
.toLocaleLowerCase()
}
function matchesVmSearch(vm: VMData, terms: string[]): boolean {
// Type synonyms come from the active locale so a user can search by the
// word they'd naturally use ("contenedor", "machine virtuelle", …) rather
// than only the internal type token. "lxc" / "qemu" already match through
// vm.type, so the catalog entries only carry the natural-language terms.
function matchesVmSearch(
vm: VMData,
terms: string[],
typeSynonyms: { lxc: string; qemu: string },
): boolean {
if (terms.length === 0) return true
const searchable = normalizeVmSearchValue([
vm.name,
vm.vmid,
vm.type,
vm.type === "lxc" ? "container kontajner" : "virtual machine virtualny stroj",
vm.type === "lxc" ? typeSynonyms.lxc : typeSynonyms.qemu,
vm.tags,
vm.description,
vm.ip,
@@ -624,6 +636,9 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
})
const flags = optsEntries.filter((o) => o.value === null).map((o) => o.key)
const keyValues = optsEntries.filter((o) => o.value !== null) as Array<{ key: string; value: string }>
const runtimeError = mp.runtime_error === "configured but not mounted"
? t("vmLxc.details.mountErrors.configuredButNotMounted")
: mp.runtime_error
return (
<div className={`rounded-lg p-4 ${cardClasses}`}>
@@ -792,13 +807,13 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
)}
{/* Error / divergence note. */}
{mp.runtime_error && (
{runtimeError && (
<p
className={`mt-3 text-sm ${
isStale ? "text-red-400" : "text-amber-400"
}`}
>
{mp.runtime_error}
{runtimeError}
</p>
)}
</div>
@@ -1120,6 +1135,12 @@ export function VirtualMachines() {
setSelectedVM(updated)
}, [vmData])
useEffect(() => {
for (const vm of vmData || []) {
if (vm.type === "lxc") syncLxcAppsState(vm.vmid, vm.app_watches || [])
}
}, [vmData])
// Backend lifecycle refreshes are asynchronous: a start response returns
// immediately, then the server waits for the guest (and Docker, for LXCs)
// before publishing a complete new snapshot. When its revision changes,
@@ -1821,15 +1842,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
updates: safeVMData.filter(hasLxcPendingUpdates).length,
}), [safeVMData])
const vmTypeSynonyms = useMemo(() => ({
lxc: t("vmLxc.filters.typeSynonyms.lxc"),
qemu: t("vmLxc.filters.typeSynonyms.qemu"),
}), [t])
const filteredVMs = useMemo(() => {
const statusMatched = statusFilter === "all"
? safeVMData
: safeVMData.filter((vm) => vm.status === statusFilter)
return statusMatched.filter((vm) => (
(!updatesOnly || hasLxcPendingUpdates(vm))
&& matchesVmSearch(vm, vmSearchTerms)
&& matchesVmSearch(vm, vmSearchTerms, vmTypeSynonyms)
))
}, [safeVMData, statusFilter, updatesOnly, vmSearchTerms])
}, [safeVMData, statusFilter, updatesOnly, vmSearchTerms, vmTypeSynonyms])
// ── LXC update apply flow (Phase 2a/b) ────────────────────────────
// Users pick a target (OS, App, both) + backup / restart options,
@@ -1872,15 +1898,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// Case-3a "no method" cards the user previously dismissed.
const [customCmdEditingApp, setCustomCmdEditingApp] = useState<string | null>(null)
const [customCmdDraft, setCustomCmdDraft] = useState<string>("")
const [updaterMethodDraft, setUpdaterMethodDraft] = useState<AppUpdateMethod>("none")
const [customCmdSaving, setCustomCmdSaving] = useState(false)
const [showHiddenNotices, setShowHiddenNotices] = useState(false)
const canonicalHelperUpdateCommand = (slug?: string | null) => {
const cleanSlug = (slug || "").trim()
if (!/^[A-Za-z0-9._-]+$/.test(cleanSlug)) return ""
return `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${cleanSlug}.sh)"`
}
// Docker Engine is updated by a protected host-side runner rather
// than by an arbitrary command inside the CT. Surface the exact
// command in the same editor used by every other app, but recognise
@@ -1890,6 +1911,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const openCustomCmdEditor = (app: LxcAppWatch, initialCommand = "") => {
setCustomCmdEditingApp(app.id)
setCustomCmdDraft(app.update_command || initialCommand)
setUpdaterMethodDraft(app.update_method || (app.update_command || initialCommand ? "custom" : "none"))
}
// ── Options card unified state ──────────────────────────────────
@@ -2389,7 +2411,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setCustomCmdSaving(true)
try {
await patchAppWatch(vmid, app, {
update_command: customCmdDraft.trim(),
update_command: updaterMethodDraft === "helper" ? "" : customCmdDraft.trim(),
update_method: app.helper_slug === "docker" ? "custom" : updaterMethodDraft,
// Saving a command implicitly re-enables the notice (moot —
// the notice only shows when there is no command).
hide_no_updater_notice: false,
@@ -2402,10 +2425,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
}
}
const removeCustomCommand = async (vmid: number, app: LxcAppWatch) => {
if (!confirm(t("vmLxc.errors.removeCustomCommandConfirm", { name: app.name }))) return
const confirmKey = app.helper_slug === "docker"
? "vmLxc.errors.removeCustomCommandConfirm"
: "vmLxc.updates.disableUpdaterConfirm"
if (!confirm(t(confirmKey, { name: app.name || "" }))) return
setCustomCmdSaving(true)
try {
await patchAppWatch(vmid, app, { update_command: "" })
await patchAppWatch(vmid, app, { update_command: "", update_method: "none" })
closeCustomCmdEditor()
} catch (e) {
alert(t("vmLxc.errors.removeCustomCommandFailed", { message: (e as any)?.message || String(e) }))
@@ -5206,7 +5232,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const scheduledAppChoices = registeredApps.filter((app) => {
if (app.helper_slug === "docker" || app.helper_slug === "adguard") return false
if (app.update_command?.trim()) return true
return helperExists && app.helper_slug === uc?.helper_slug
return helperExists && app.update_method === "helper" && app.helper_slug === uc?.helper_slug
}).map((app) => ({ id: `app:${app.id}`, label: app.name }))
const versionTrackedScheduleAppIds = new Set(
registeredApps
@@ -5275,6 +5301,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
if (app.helper_slug === "docker") return false
if (app.update_command?.trim()) return true
return helperExists
&& app.update_method === "helper"
&& app.helper_slug === uc?.helper_slug
&& app.helper_slug !== "adguard"
}).map((app) => {
@@ -5801,9 +5828,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{helperSectionDetected && (() => {
const matchApp = helperOnlyApps[0] || null
if (!matchApp || customCmdEditingApp === matchApp.id) return null
const helperEditorCommand = helperExists && !helperUsesWebUpdater
? canonicalHelperUpdateCommand(uc?.helper_slug)
: ""
const helperSelected = matchApp.update_method === "helper"
const appWebUrl = helperUsesWebUpdater
? buildRegisteredAppUrl(selectedVM, matchApp.ports?.[0])
: null
@@ -5823,12 +5848,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
type="button"
onClick={() => openCustomCmdEditor(
matchApp,
helperEditorCommand,
)}
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0"
>
<Settings2 className="h-3.5 w-3.5" />
{helperEditorCommand
{helperSelected
? t("vmLxc.updates.editApp")
: t("vmLxc.updates.configureUpdater")}
</button>
@@ -5887,7 +5911,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.versionTrackingNotConfigured")}
</p>
) : null}
{helperExists && !helperUsesWebUpdater && (() => {
{helperExists && !helperUsesWebUpdater && !helperSelected && (
<p className="mt-3 text-xs text-muted-foreground">{t("vmLxc.updates.noUpdaterSelected")}</p>
)}
{helperExists && !helperUsesWebUpdater && helperSelected && (() => {
// Button state uses the matched
// App Watch entry when present.
// Without it we DON'T know the
@@ -5904,7 +5931,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
return (
<div className="mt-3 flex justify-end">
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app", { runHelper: true, appName: helperName || "" })} className={cls}>
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app", { runHelper: true, appName: helperName || "", targetIds: [`app:${matchApp.id}`] })} className={cls}>
{hasUpd && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{noState && <RefreshCw className="h-4 w-4 mr-1.5" />}
{label}
@@ -5965,7 +5992,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0"
>
<Settings2 className="h-3.5 w-3.5" />
{hasCmd
{hasCmd || aw.update_method === "helper"
? t("vmLxc.updates.editApp")
: t("vmLxc.updates.configureUpdater")}
</button>
@@ -5977,56 +6004,22 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
)}
{editing ? (
<div className="space-y-3">
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.updates.customCommandLabel")}
</Label>
<Textarea
value={customCmdDraft}
onChange={(e) => setCustomCmdDraft(e.target.value)}
placeholder={t("vmLxc.updates.customCommandPlaceholder")}
className="font-mono text-xs mt-2 min-h-[100px]"
maxLength={4096}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div>
{hasCmd && (
<button
type="button"
onClick={() => removeCustomCommand(selectedVM.vmid, aw)}
disabled={customCmdSaving}
className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Trash2 className="h-3.5 w-3.5" />
{t("vmLxc.updates.removeButton")}
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeCustomCmdEditor}
disabled={customCmdSaving}
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
>
{t("vmLxc.updates.cancelButton")}
</button>
<button
type="button"
onClick={() => saveCustomCommand(selectedVM.vmid, aw)}
disabled={customCmdSaving || !customCmdDraft.trim() || (
customCmdDraft.trim() === (aw.update_command || "").trim()
)}
className="h-8 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
>
{customCmdSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{t("vmLxc.updates.saveButton")}
</button>
</div>
</div>
</div>
<AppUpdaterEditor
method={updaterMethodDraft}
command={customCmdDraft}
helperAvailable={helperExists && !helperUsesWebUpdater && aw.helper_slug === uc?.helper_slug}
helperSlug={aw.helper_slug}
configured={hasCmd || aw.update_method === "helper"}
saving={customCmdSaving}
changed={updaterMethodDraft !== (aw.update_method || (hasCmd ? "custom" : "none")) || (
updaterMethodDraft === "custom" && customCmdDraft.trim() !== (aw.update_command || "").trim()
)}
onMethodChange={setUpdaterMethodDraft}
onCommandChange={setCustomCmdDraft}
onSave={() => saveCustomCommand(selectedVM.vmid, aw)}
onCancel={closeCustomCmdEditor}
onRemove={() => removeCustomCommand(selectedVM.vmid, aw)}
/>
) : (
<>
{hasUpdate ? (
+82 -5
View File
@@ -17,6 +17,82 @@ export type LxcAppsBundle = {
const dataCache = new Map<number, LxcAppsBundle>()
const inFlight = new Map<number, Promise<LxcAppsBundle | null>>()
const cacheRevision = new Map<number, number>()
type AppWatchState = {
id: string
state_revision?: number
managed_oci_app_id?: string | null
checked_at?: string | null
[key: string]: any
}
const observedStates = new Map<number, AppWatchState[]>()
const listeners = new Map<number, Set<(bundle: LxcAppsBundle) => void>>()
const stateFields = [
"installed_version", "latest_version", "latest_published_at",
"update_available", "error", "checked_at",
] as const
function withObservedStates(vmid: number, sidecar: any): any {
if (!Array.isArray(sidecar?.apps)) return sidecar
const observations = new Map((observedStates.get(vmid) || []).map(item => [item.id, item]))
let changed = false
let revision = sidecar._revision || 0
const apps = sidecar.apps.map((app: any) => {
const observed = observations.get(app.id)
if (!observed || observed.managed_oci_app_id) return app
if (observed.state_revision && sidecar._revision) {
if (observed.state_revision < sidecar._revision) return app
} else if (!observed.checked_at || (app.state?.checked_at && observed.checked_at < app.state.checked_at)) {
return app
}
const state = { ...app.state }
let stateChanged = false
for (const field of stateFields) {
if (field in observed && observed[field] !== state[field]) {
state[field] = observed[field]
stateChanged = true
}
}
revision = Math.max(revision, observed.state_revision || 0)
if (!stateChanged) return app
changed = true
return { ...app, state }
})
return changed || revision !== (sidecar._revision || 0)
? { ...sidecar, apps, _revision: revision }
: sidecar
}
function publish(vmid: number, bundle: LxcAppsBundle): void {
dataCache.set(vmid, bundle)
listeners.get(vmid)?.forEach(listener => listener(bundle))
}
export function subscribeLxcApps(vmid: number, listener: (bundle: LxcAppsBundle) => void): () => void {
const subscribers = listeners.get(vmid) || new Set()
subscribers.add(listener)
listeners.set(vmid, subscribers)
const current = dataCache.get(vmid)
if (current) listener(current)
return () => {
subscribers.delete(listener)
if (!subscribers.size) listeners.delete(vmid)
}
}
// Reuse the existing VM-list feed; this never starts a detection or an HTTP request.
export function syncLxcAppsState(vmid: number, watches: AppWatchState[]): void {
const previous = observedStates.get(vmid) || []
const previousRevision = Math.max(0, ...previous.map(item => item.state_revision || 0))
const nextRevision = Math.max(0, ...watches.map(item => item.state_revision || 0))
if (nextRevision && nextRevision < previousRevision) return
observedStates.set(vmid, watches)
const current = dataCache.get(vmid)
if (!current) return
const sidecar = withObservedStates(vmid, current.sidecar)
if (sidecar === current.sidecar) return
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
publish(vmid, { ...current, sidecar })
}
export function getLxcAppsCached(vmid: number): LxcAppsBundle | undefined {
return dataCache.get(vmid)
@@ -34,12 +110,12 @@ export function setLxcAppsCached(
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
const current = dataCache.get(vmid)
const bundle: LxcAppsBundle = {
sidecar,
sidecar: withObservedStates(vmid, sidecar),
suggestions: suggestions === undefined
? (current?.suggestions ?? null)
: suggestions,
}
dataCache.set(vmid, bundle)
publish(vmid, bundle)
return bundle
}
@@ -53,13 +129,13 @@ export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
])
.then(([sc, sug]) => {
if (!sc) return null
const bundle: LxcAppsBundle = { sidecar: sc, suggestions: sug }
const bundle: LxcAppsBundle = { sidecar: withObservedStates(vmid, sc), suggestions: sug }
// A successful write may have completed while these GETs were in
// flight. Never let that older response overwrite the mutation result.
if ((cacheRevision.get(vmid) || 0) !== startedRevision) {
return dataCache.get(vmid) ?? null
}
dataCache.set(vmid, bundle)
publish(vmid, bundle)
return bundle
})
.finally(() => {
@@ -72,6 +148,7 @@ export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
export function invalidateLxcApps(vmid: number): void {
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
dataCache.delete(vmid)
observedStates.delete(vmid)
}
// Seed the cache from the bulk modal-cache endpoint. Both registered
@@ -85,5 +162,5 @@ export function seedLxcAppsCache(
if (!sidecar) return
const existing = dataCache.get(vmid)
if (existing) return // per-panel fetch already ran, don't overwrite
dataCache.set(vmid, { sidecar, suggestions: suggestions ?? null })
publish(vmid, { sidecar: withObservedStates(vmid, sidecar), suggestions: suggestions ?? null })
}
+190 -14
View File
@@ -45,7 +45,8 @@
"profile": "Profil",
"node": "Knoten",
"admin": "Admin",
"menu": "Navigationsmenü"
"menu": "Navigationsmenü",
"audit": "Audit & Report"
},
"status": {
"healthy": "Gesund",
@@ -72,6 +73,8 @@
"networkErrorWhileSaving": "Netzwerkfehler beim Speichern"
},
"overview": {
"metricsTimeout": "Die Metrikabfrage hat länger als 30 Sekunden gedauert. ProxMenux versucht es automatisch erneut.",
"metricsCachedWarning": "Die Metriken konnten nicht aktualisiert werden. Angezeigt werden die am {time} abgerufenen Daten.",
"loadingTitle": "Systemübersicht wird geladen...",
"loadingDescription": "Systemstatus und -metriken abrufen",
"topProcessesCpu": "Top-Prozesse nach CPU anzeigen",
@@ -882,7 +885,7 @@
"executable": "Ausführbar",
"workingDir": "Arbeitsverzeichnis",
"started": "Begonnen",
"runningFor": "Laufen für"
"runningFor": "Läuft seit"
},
"states": {
"running": "Läuft",
@@ -1030,7 +1033,10 @@
"startOnBoot": "Beim Booten beginnen",
"tags": "Tags",
"tagsPlaceholder": "Tag hinzufügen…",
"tagsNone": "Keine Tags"
"tagsNone": "Keine Tags",
"mountErrors": {
"configuredButNotMounted": "Konfiguriert, aber nicht gemountet"
}
},
"logs": {
"header": "Protokolle für {name} (VMID: {vmid})",
@@ -1214,7 +1220,15 @@
"humanWeekly": "Wöchentlich ({day} {time})",
"humanMonthly": "Monatlich (Tag {day} um {time})",
"humanHourly": "Stündlich",
"weekdays": "['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']"
"weekdays": [
"Sonntag",
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag"
]
},
"cronChip": {
"detected": "Host-Cron erkannt",
@@ -1241,6 +1255,35 @@
"osStatusUnavailable": "Der Status der Betriebssystemaktualisierungen konnte nicht ermittelt werden.",
"installedByHelperPrefix": "Installiert von",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperCommandEditHint": "Wenn du diesen Aufruf bearbeitest, wird er als benutzerdefinierter Befehl gespeichert.",
"customOnlineDescription": "Oder verwende ein vom Entwickler bereitgestelltes Online-Skript:",
"customOnlineExampleNote": "Ersetze die Beispiel-URL durch die URL des offiziellen Skripts deiner Anwendung.",
"customExamplesHeading": "Beispiele zum Anpassen:",
"updaterChoiceHint": "Wähle eine Aktualisierungsmethode und speichere sie.",
"updaterMethodLabel": "Aktualisierungsmethode",
"helperMethod": "Helper-Scripts",
"customMethod": "Eigener Befehl",
"helperMethodHelp": "Über Helper-Scripts",
"customMethodHelp": "Über eigene Befehle",
"helperDetectedChoice": "Helper-Scripts-Updater für diese Anwendung erkannt.",
"helperUnavailableChoice": "Der ausgewählte Helper-Scripts-Updater ist nicht mehr verfügbar.",
"disableUpdater": "Deaktivieren",
"disableUpdaterConfirm": "Aktualisierungsmethode für {name} deaktivieren? Gespeicherte Pläne und Zeitpläne behalten ihre Auswahl, führen diese Anwendung aber erst wieder aus, wenn eine Methode konfiguriert ist.",
"noUpdaterSelected": "Du hast keine Aktualisierungsmethode ausgewählt.",
"helperMethodDescription": "ProxMenux führt den erkannten Updater in diesem LXC aus und zeigt dessen Protokoll und Ergebnis. Der Updater wird von Proxmox VE Helper-Scripts gepflegt.",
"customMethodDescription": "Du kannst den Updater der Anwendung, einen Paketmanager oder eine neue Binärdatei verwenden. Der Befehl wird in diesem LXC mit Administratorrechten ausgeführt.",
"updaterInstructions": "Lies die Hinweise des Updaters.",
"helperDocumentation": "Aktualisierungsdokumentation",
"helperSource": "Anwendungsskript",
"customMethodExample": "Passe Namen, Pfade und Dienst an deine Anwendung an. Nur der gespeicherte Befehl wird ausgeführt.",
"helperCommandLabel": "Befehl innerhalb des LXC",
"helperCommandFallback": "Wenn wget nicht verfügbar ist, lädt ProxMenux dasselbe Skript mit curl -fsSL herunter.",
"customScriptTitle": "Aktualisierungsskript",
"customScriptDescription": "Führe ein bereits im LXC vorhandenes Aktualisierungsskript der Anwendung aus.",
"customPackageTitle": "Debian/Ubuntu-Paket",
"customPackageDescription": "Ersetze my-package durch das Paket, das du aktualisieren möchtest.",
"customBinaryTitle": "Binärdatei",
"customBinaryDescription": "Lade zuerst die offizielle Binärdatei für die richtige Version und Architektur nach /tmp/my-app.new herunter und überprüfe sie. Dieses Beispiel sichert die bisherige Binärdatei und startet den zugehörigen Dienst neu.",
"helperUpdatesRun": "Installiert von Proxmox Helper-Scripts.",
"installedPrefix": "installiert",
"upstreamAvailable": "Version {version} verfügbar",
@@ -1564,22 +1607,26 @@
"excludeFromBadgeHelp": "Zählen Sie diese App nicht im Abzeichen „Aggregate Updates“ auf der LXC-Listenkarte.Nützlich, wenn Sie absichtlich an eine bestimmte Version gebunden sind (Tracker-Anforderung, Kompatibilitätsstopp).Hat keinen Einfluss auf den eigenen Status der App-Registerkarte oder die ausgehende Benachrichtigung."
},
"statusFilter": {
"ariaLabel": "Virtuelle Maschinen und Container nach Status filtern",
"ariaLabel": "Virtuelle Maschinen und Container filtern",
"all": "Alle",
"running": "Laufen",
"running": "Läuft",
"stopped": "Gestoppt",
"empty": "Keine virtuellen Maschinen oder Container mit dem Status „{status}“"
},
"filters": {
"searchPlaceholder": "Nach Name, ID, Tag oder Notiz suchen …",
"searchAriaLabel": "Durchsuchen Sie virtuelle Maschinen und Container",
"searchPlaceholder": "Name, ID, Tag oder Notiz suchen…",
"searchAriaLabel": "Virtuelle Maschinen und Container durchsuchen",
"clearSearch": "Suche löschen",
"updates": "Aktualisierungen",
"updates": "Updates",
"resultCount": "{shown} von {total} Maschinen",
"noMatches": "Keine VMs oder LXCs entsprechen Ihrer Suche.",
"noMatches": "Keine VMs oder LXCs entsprechen der Suche.",
"noRunning": "Keine laufenden VMs oder LXCs.",
"noStopped": "Keine gestoppten VMs oder LXCs.",
"noUpdates": "Nein LXC mit verfügbaren Updates."
"noUpdates": "Kein LXC mit verfügbaren Updates.",
"typeSynonyms": {
"lxc": "container",
"qemu": "vm virtuelle maschine virtuelle maschinen"
}
}
},
"settings": {
@@ -4062,7 +4109,7 @@
"loadingJob": "Job wird geladen...",
"manualOneShot": "manuell / One-Shot",
"manualOneShotDescription": "One-Shot-Backup zum Zeitpunkt des Auslösers erfasst. Kann nicht erneut ausgeführt oder bearbeitet werden.",
"neverRun": "niemals laufen",
"neverRun": "nie ausgeführt",
"newScheduledJob": "Neuer geplanter Auftrag",
"newScheduledJobDescription": "Erstellen Sie einen wiederkehrenden Host-Sicherungsauftrag.",
"nextRun": "Nächster Lauf",
@@ -4193,7 +4240,7 @@
"inProgress": "Manuelle Sicherung läuft",
"oneShotDescription": "One-Shot-Backups werden als eingefrorene Jobs aufbewahrt, sodass Sie ihr Protokoll später überprüfen können.",
"reopenLogTitle": "Protokoll erneut öffnen",
"run": "Laufen",
"run": "Ausführen",
"runBackup": "Führen Sie ein Backup aus",
"runOneShotBackup": "Führen Sie ein One-Shot-Backup durch",
"title": "Manuelle Sicherung",
@@ -4349,7 +4396,7 @@
"hourlyHelpBefore": "Im Moment",
"hourlyHelpMiddle": "der Timer",
"hourlyOption": "Stündlich",
"howToSchedule": "Wie soll es laufen?",
"howToSchedule": "Wie soll es ausgeführt werden?",
"janFirst": "Jedes Jahr am 1. Januar",
"modeHelp": "Wählen Sie einen einfachen Zeitplan oder geben Sie einen systemd-OnCalendar-Ausdruck ein.",
"monthlyOption": "Monatlich",
@@ -4861,5 +4908,134 @@
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
},
"audit": {
"title": "Audit & Report",
"loading": "Loading assessment…",
"run": "Run assessment",
"running": "Assessing…",
"neverRun": "This host has not been assessed yet.",
"lastRun": "Last assessed on {when}",
"stale": "{days} days ago",
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
"noFindings": "No findings match the current filter.",
"showPassing": "Show passing checks",
"hidePassing": "Hide passing checks",
"affectedCount": "{count} affected",
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
"states": {
"fail": "Failed",
"warn": "Warning",
"accepted": "Accepted risk",
"pass": "Passed",
"not_applicable": "Not applicable"
},
"areas": {
"all": "All",
"system": "System",
"storage": "Storage",
"network": "Network",
"security": "Security",
"backup": "Backup",
"guests": "Guests",
"hardware": "Hardware"
},
"detail": {
"why": "Context",
"evidence": "Evidence",
"affected": "Affected",
"acceptedRisk": "Accepted risk"
},
"errors": {
"runFailed": "The assessment could not be started."
},
"checks": {
"backup": {
"guest_coverage": {
"title": "Backup coverage",
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
"summary": {
"noJobs": "No backup job is defined on this node",
"covered": "All {total} guests are covered by a backup job",
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
}
}
},
"system": {
"pending_reboot": {
"title": "Restart state",
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
"summary": {
"none": "No restart is pending",
"pending": "The host has a pending restart"
}
},
"enterprise_repo_without_subscription": {
"title": "Enterprise repository",
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
"summary": {
"notEnabled": "The enterprise repository is not enabled",
"subscribed": "The enterprise repository is backed by a subscription",
"unsubscribed": "The enterprise repository is enabled without an active subscription"
}
}
},
"guests": {
"privileged_containers": {
"title": "Container privileges",
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
"summary": {
"allUnprivileged": "All {total} containers are unprivileged",
"privileged": "{count} of {total} containers run privileged"
}
},
"qemu_without_agent": {
"title": "Guest agent on virtual machines",
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
"summary": {
"allHaveAgent": "All {total} virtual machines declare the guest agent",
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
}
}
},
"security": {
"host_firewall_enabled": {
"title": "Firewall state",
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
"summary": {
"bothEnabled": "The firewall is enabled at datacenter and node level",
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
}
}
},
"storage": {
"orphaned_volumes": {
"title": "Volume assignment",
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
"summary": {
"none": "No orphaned volumes were found",
"found": "{count} volume(s) belong to no existing guest"
}
}
}
},
"summaryFallback": "The check could not be evaluated",
"acceptRisk": {
"action": "Accept risk",
"revoke": "Return to active",
"title": "Accept this risk",
"reasonLabel": "Reason",
"reasonHelp": "Required. It is recorded together with the author and the date.",
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
"expiryLabel": "Review after",
"expiryHelp": "When the period ends the finding becomes active again.",
"expiryNever": "Does not expire",
"expiry90": "90 days",
"expiry180": "180 days",
"expiry365": "1 year",
"cancel": "Cancel",
"confirm": "Accept risk"
}
}
}
+179 -3
View File
@@ -44,7 +44,8 @@
"profile": "Profile",
"node": "Node",
"admin": "Admin",
"menu": "Navigation Menu"
"menu": "Navigation Menu",
"audit": "Audit & Report"
},
"status": {
"healthy": "Healthy",
@@ -71,6 +72,8 @@
"networkErrorWhileSaving": "Network error while saving"
},
"overview": {
"metricsTimeout": "The metrics query exceeded 30 seconds. ProxMenux will retry automatically.",
"metricsCachedWarning": "Could not refresh metrics. Showing data retrieved at {time}.",
"loadingTitle": "Loading system overview...",
"loadingDescription": "Fetching system status and metrics",
"topProcessesCpu": "View top processes by CPU",
@@ -932,7 +935,11 @@
"noMatches": "No VMs or LXCs match your search.",
"noRunning": "No running VMs or LXCs.",
"noStopped": "No stopped VMs or LXCs.",
"noUpdates": "No LXC with available updates."
"noUpdates": "No LXC with available updates.",
"typeSynonyms": {
"lxc": "container containers",
"qemu": "vm vms virtual machine virtual machines"
}
},
"uptime": "Uptime: {uptime}",
"cpuUsage": "CPU Usage",
@@ -1044,6 +1051,9 @@
"stopped": "stopped",
"mounted": "mounted"
},
"mountErrors": {
"configuredButNotMounted": "Configured but not mounted"
},
"startOnBoot": "Start at boot",
"tags": "Tags",
"tagsPlaceholder": "Add tag…",
@@ -1231,7 +1241,15 @@
"humanWeekly": "Weekly ({day} {time})",
"humanMonthly": "Monthly (day {day} at {time})",
"humanHourly": "Hourly",
"weekdays": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
"weekdays": [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
]
},
"cronChip": {
"detected": "host cron detected",
@@ -1258,6 +1276,35 @@
"osStatusUnavailable": "The OS update status could not be determined.",
"installedByHelperPrefix": "Installed by",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperCommandEditHint": "If you edit this launcher, it will be saved as a custom command.",
"customOnlineDescription": "Or use an online script provided by the developer:",
"customOnlineExampleNote": "Replace the example URL with the official script URL for your application.",
"customExamplesHeading": "Examples to adapt:",
"updaterChoiceHint": "Choose and save an update method.",
"updaterMethodLabel": "Update method",
"helperMethod": "Helper-Scripts",
"customMethod": "Custom command",
"helperMethodHelp": "About Helper-Scripts",
"customMethodHelp": "About custom commands",
"helperDetectedChoice": "Helper-Scripts updater detected for this application.",
"helperUnavailableChoice": "The selected Helper-Scripts updater is no longer available.",
"disableUpdater": "Disable",
"disableUpdaterConfirm": "Disable the update method for {name}? Saved plans and schedules will keep their selections, but this application will not run until a method is configured again.",
"noUpdaterSelected": "You have not selected an update method.",
"helperMethodDescription": "ProxMenux runs the detected updater inside this LXC and displays its log and result. The updater is maintained by Proxmox VE Helper-Scripts.",
"customMethodDescription": "You can use the application's own updater, a package manager or a new binary. The command runs inside this LXC with administrator privileges.",
"updaterInstructions": "Review the updater's instructions.",
"helperDocumentation": "Update documentation",
"helperSource": "Application script",
"customMethodExample": "Adapt the names, paths and service to your application. Only the command you save will run.",
"helperCommandLabel": "Command inside the LXC",
"helperCommandFallback": "If wget is unavailable, ProxMenux uses curl -fsSL to download the same script.",
"customScriptTitle": "Update script",
"customScriptDescription": "Run an existing application update script inside the LXC.",
"customPackageTitle": "Debian/Ubuntu package",
"customPackageDescription": "Replace my-package with the package you want to update.",
"customBinaryTitle": "Binary",
"customBinaryDescription": "First download and verify the official binary for the correct version and architecture at /tmp/my-app.new. This example keeps a backup of the previous binary and restarts its service.",
"helperUpdatesRun": "Installed by Proxmox Helper-Scripts.",
"installedPrefix": "installed",
"upstreamAvailable": "upstream {version} available",
@@ -4933,5 +4980,134 @@
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
},
"audit": {
"title": "Audit & Report",
"loading": "Loading assessment…",
"run": "Run assessment",
"running": "Assessing…",
"neverRun": "This host has not been assessed yet.",
"lastRun": "Last assessed on {when}",
"stale": "{days} days ago",
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
"noFindings": "No findings match the current filter.",
"showPassing": "Show passing checks",
"hidePassing": "Hide passing checks",
"affectedCount": "{count} affected",
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
"states": {
"fail": "Failed",
"warn": "Warning",
"accepted": "Accepted risk",
"pass": "Passed",
"not_applicable": "Not applicable"
},
"areas": {
"all": "All",
"system": "System",
"storage": "Storage",
"network": "Network",
"security": "Security",
"backup": "Backup",
"guests": "Guests",
"hardware": "Hardware"
},
"detail": {
"why": "Context",
"evidence": "Evidence",
"affected": "Affected",
"acceptedRisk": "Accepted risk"
},
"errors": {
"runFailed": "The assessment could not be started."
},
"checks": {
"backup": {
"guest_coverage": {
"title": "Backup coverage",
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
"summary": {
"noJobs": "No backup job is defined on this node",
"covered": "All {total} guests are covered by a backup job",
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
}
}
},
"system": {
"pending_reboot": {
"title": "Restart state",
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
"summary": {
"none": "No restart is pending",
"pending": "The host has a pending restart"
}
},
"enterprise_repo_without_subscription": {
"title": "Enterprise repository",
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
"summary": {
"notEnabled": "The enterprise repository is not enabled",
"subscribed": "The enterprise repository is backed by a subscription",
"unsubscribed": "The enterprise repository is enabled without an active subscription"
}
}
},
"guests": {
"privileged_containers": {
"title": "Container privileges",
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
"summary": {
"allUnprivileged": "All {total} containers are unprivileged",
"privileged": "{count} of {total} containers run privileged"
}
},
"qemu_without_agent": {
"title": "Guest agent on virtual machines",
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
"summary": {
"allHaveAgent": "All {total} virtual machines declare the guest agent",
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
}
}
},
"security": {
"host_firewall_enabled": {
"title": "Firewall state",
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
"summary": {
"bothEnabled": "The firewall is enabled at datacenter and node level",
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
}
}
},
"storage": {
"orphaned_volumes": {
"title": "Volume assignment",
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
"summary": {
"none": "No orphaned volumes were found",
"found": "{count} volume(s) belong to no existing guest"
}
}
}
},
"summaryFallback": "The check could not be evaluated",
"acceptRisk": {
"action": "Accept risk",
"revoke": "Return to active",
"title": "Accept this risk",
"reasonLabel": "Reason",
"reasonHelp": "Required. It is recorded together with the author and the date.",
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
"expiryLabel": "Review after",
"expiryHelp": "When the period ends the finding becomes active again.",
"expiryNever": "Does not expire",
"expiry90": "90 days",
"expiry180": "180 days",
"expiry365": "1 year",
"cancel": "Cancel",
"confirm": "Accept risk"
}
}
}
+190 -14
View File
@@ -45,7 +45,8 @@
"profile": "Perfil",
"node": "Nodo",
"admin": "Administración",
"menu": "Menú de navegación"
"menu": "Menú de navegación",
"audit": "Auditoría e informes"
},
"status": {
"healthy": "OK",
@@ -72,6 +73,8 @@
"networkErrorWhileSaving": "Error de red al guardar"
},
"overview": {
"metricsTimeout": "La consulta de métricas ha superado los 30 segundos. ProxMenux volverá a intentarlo automáticamente.",
"metricsCachedWarning": "No se han podido actualizar las métricas. Se muestran los datos obtenidos el {time}.",
"loadingTitle": "Cargando descripción general del sistema...",
"loadingDescription": "Obtener el estado y las métricas del sistema",
"topProcessesCpu": "Ver procesos principales por CPU",
@@ -882,7 +885,7 @@
"executable": "Ejecutable",
"workingDir": "Directorio de trabajo",
"started": "Comenzó",
"runningFor": "Corriendo por"
"runningFor": "En ejecución desde"
},
"states": {
"running": "En ejecución",
@@ -1030,7 +1033,10 @@
"startOnBoot": "Iniciar al arrancar",
"tags": "Etiquetas",
"tagsPlaceholder": "Añadir etiqueta…",
"tagsNone": "Sin etiquetas"
"tagsNone": "Sin etiquetas",
"mountErrors": {
"configuredButNotMounted": "Configurado pero no montado"
}
},
"logs": {
"header": "Registros para {name} (VMID: {vmid})",
@@ -1214,7 +1220,15 @@
"humanWeekly": "Semanal ({day} {time})",
"humanMonthly": "Mensual (día {day} a las {time})",
"humanHourly": "cada hora",
"weekdays": "['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado']"
"weekdays": [
"domingo",
"lunes",
"martes",
"miércoles",
"jueves",
"viernes",
"sábado"
]
},
"cronChip": {
"detected": "cron del host detectado",
@@ -1241,6 +1255,35 @@
"osStatusUnavailable": "No se ha podido determinar el estado de las actualizaciones del SO.",
"installedByHelperPrefix": "Instalado por",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperCommandEditHint": "Si modificas este lanzador, se guardará como comando personalizado.",
"customOnlineDescription": "O utiliza un script online proporcionado por el desarrollador:",
"customOnlineExampleNote": "Sustituye la URL de ejemplo por la del script oficial de tu aplicación.",
"customExamplesHeading": "Ejemplos para adaptar:",
"updaterChoiceHint": "Elige y guarda un método de actualización.",
"updaterMethodLabel": "Método de actualización",
"helperMethod": "Helper-Scripts",
"customMethod": "Comando personalizado",
"helperMethodHelp": "Acerca de Helper-Scripts",
"customMethodHelp": "Acerca del comando personalizado",
"helperDetectedChoice": "Actualizador de Helper-Scripts detectado para esta aplicación.",
"helperUnavailableChoice": "El actualizador de Helper-Scripts seleccionado ya no está disponible.",
"disableUpdater": "Desactivar",
"disableUpdaterConfirm": "¿Desactivar el método de actualización de {name}? Los planes y las programaciones conservarán sus selecciones, pero esta aplicación no se ejecutará hasta que vuelvas a configurar un método.",
"noUpdaterSelected": "No has seleccionado un método de actualización.",
"helperMethodDescription": "ProxMenux ejecuta el actualizador detectado dentro de este LXC y muestra su registro y resultado. Este lo mantiene Proxmox VE Helper-Scripts.",
"customMethodDescription": "Puedes utilizar el actualizador de la aplicación, un gestor de paquetes o un binario nuevo. El comando se ejecuta dentro de este LXC con privilegios de administrador.",
"updaterInstructions": "Revisa las indicaciones del actualizador.",
"helperDocumentation": "Documentación de actualización",
"helperSource": "Script de la aplicación",
"customMethodExample": "Adapta los nombres, las rutas y el servicio a tu aplicación. Solo se ejecutará el comando que guardes.",
"helperCommandLabel": "Comando dentro del LXC",
"helperCommandFallback": "Si wget no está disponible, ProxMenux utiliza curl -fsSL para descargar el mismo script.",
"customScriptTitle": "Script de actualización",
"customScriptDescription": "Ejecuta un script de actualización de la aplicación que ya exista en el LXC.",
"customPackageTitle": "Paquete Debian/Ubuntu",
"customPackageDescription": "Sustituye my-package por el paquete que quieras actualizar.",
"customBinaryTitle": "Binario",
"customBinaryDescription": "Primero descarga y verifica el binario oficial de la versión y arquitectura correctas en /tmp/my-app.new. Este ejemplo conserva una copia del binario anterior y reinicia su servicio.",
"helperUpdatesRun": "Instalado por Proxmox Helper-Scripts.",
"installedPrefix": "instalado",
"upstreamAvailable": "versión {version} disponible",
@@ -1564,22 +1607,26 @@
"excludeFromBadgeHelp": "No sumar esta app al contador agregado de actualizaciones del card del LXC. Útil cuando mantienes una versión concreta a propósito (requisito de tracker, compatibilidad). No afecta al estado que se muestra en la pestaña App ni al envío de la notificación."
},
"statusFilter": {
"ariaLabel": "filtrar máquinas virtuales y contenedores por estado",
"ariaLabel": "Filtrar máquinas virtuales y contenedores",
"all": "Todos",
"running": "corriendo",
"stopped": "detenido",
"running": "En ejecución",
"stopped": "Detenido",
"empty": "No hay máquinas virtuales ni contenedores con estado \"{status}\""
},
"filters": {
"searchPlaceholder": "buscar nombre, ID, etiqueta o nota...",
"searchAriaLabel": "buscar máquinas virtuales y contenedores",
"searchPlaceholder": "Buscar por nombre, ID, etiqueta o nota",
"searchAriaLabel": "Buscar máquinas virtuales y contenedores",
"clearSearch": "Borrar búsqueda",
"updates": "Actualizaciones",
"resultCount": "{shown} de {total} máquinas",
"noMatches": "Ninguna VM o LXC coincide con su búsqueda.",
"noRunning": "No se ejecutan VM ni LXC.",
"noStopped": "No hay máquinas virtuales ni LXC detenidos.",
"noUpdates": "No LXC con actualizaciones disponibles."
"noMatches": "Ninguna VM ni LXC coincide con la búsqueda.",
"noRunning": "No hay VM ni LXC en ejecución.",
"noStopped": "No hay VM ni LXC detenidas.",
"noUpdates": "Ningún LXC tiene actualizaciones disponibles.",
"typeSynonyms": {
"lxc": "contenedor contenedores",
"qemu": "vm máquina virtual máquinas virtuales"
}
}
},
"settings": {
@@ -2714,7 +2761,7 @@
"executiveSummary": "Resumen ejecutivo",
"hardeningAssessment": "Evaluación de endurecimiento",
"auditOf": "Auditoría de",
"running": "corriendo",
"running": "ejecutándose en",
"unknownOs": "sistema operativo desconocido",
"noActionableWarnings": "Sin advertencias procesables",
"and": "y",
@@ -4861,5 +4908,134 @@
"customLinkDelete": "Eliminar",
"customLinkSaveError": "Error al guardar",
"customLinkDeleteError": "Error al eliminar"
},
"audit": {
"title": "Auditoría e informes",
"loading": "Cargando evaluación…",
"run": "Ejecutar evaluación",
"running": "Evaluando…",
"neverRun": "Este host aún no se ha evaluado.",
"lastRun": "Última evaluación el {when}",
"stale": "hace {days} días",
"readOnlyNotice": "La evaluación solo lee el host. No realiza ningún cambio.",
"noFindings": "Ningún hallazgo coincide con el filtro actual.",
"showPassing": "Mostrar comprobaciones correctas",
"hidePassing": "Ocultar comprobaciones correctas",
"affectedCount": "{count} afectados",
"acceptedNotice": "{count} riesgo(s) aceptado(s) registrados en este host.",
"states": {
"fail": "Fallo",
"warn": "Aviso",
"accepted": "Riesgo aceptado",
"pass": "Correcto",
"not_applicable": "No aplicable"
},
"areas": {
"all": "Todas",
"system": "Sistema",
"storage": "Almacenamiento",
"network": "Red",
"security": "Seguridad",
"backup": "Backups",
"guests": "Invitados",
"hardware": "Hardware"
},
"detail": {
"why": "Contexto",
"evidence": "Evidencia",
"affected": "Afectados",
"acceptedRisk": "Riesgo aceptado"
},
"errors": {
"runFailed": "No se pudo iniciar la evaluación."
},
"checks": {
"backup": {
"guest_coverage": {
"title": "Cobertura de backups",
"rationale": "Compara el inventario de invitados con la selección de cada trabajo de backup. Un trabajo selecciona por lista de VMID, por pool o con `all 1`, restando su lista `exclude`. Los trabajos con `enabled 0` no se consideran.",
"summary": {
"noJobs": "No hay ningún trabajo de backup definido en este nodo",
"covered": "Los {total} invitados están cubiertos por un trabajo de backup",
"uncovered": "{count} de {total} invitados no están cubiertos por ningún trabajo de backup activo"
}
}
},
"system": {
"pending_reboot": {
"title": "Estado de reinicio",
"rationale": "Comprueba la presencia de `/var/run/reboot-required` y, cuando existe, los paquetes listados en `/var/run/reboot-required.pkgs`. Informa también del kernel en ejecución.",
"summary": {
"none": "No hay ningún reinicio pendiente",
"pending": "El host tiene un reinicio pendiente"
}
},
"enterprise_repo_without_subscription": {
"title": "Repositorio enterprise",
"rationale": "Busca referencias a `enterprise.proxmox.com` en `/etc/apt/sources.list` y en `sources.list.d`, y contrasta el resultado con el estado que devuelve `pvesubscription get`.",
"summary": {
"notEnabled": "El repositorio enterprise no está habilitado",
"subscribed": "El repositorio enterprise cuenta con suscripción",
"unsubscribed": "El repositorio enterprise está habilitado sin suscripción activa"
}
}
},
"guests": {
"privileged_containers": {
"title": "Privilegios de los contenedores",
"rationale": "Revisa la configuración de cada contenedor. Proxmox marca los contenedores sin privilegios con `unprivileged: 1`; su ausencia indica un contenedor privilegiado, que comparte el espacio de nombres de usuario del host.",
"summary": {
"allUnprivileged": "Los {total} contenedores son sin privilegios",
"privileged": "{count} de {total} contenedores se ejecutan con privilegios"
}
},
"qemu_without_agent": {
"title": "Agente invitado en máquinas virtuales",
"rationale": "Revisa la configuración de cada máquina virtual en busca de `agent: 1`. El agente invitado habilita el apagado ordenado, la congelación del sistema de archivos para instantáneas y el informe de uso real de disco.",
"summary": {
"allHaveAgent": "Las {total} máquinas virtuales declaran el agente invitado",
"missingAgent": "{count} de {total} máquinas virtuales no declaran el agente invitado"
}
}
},
"security": {
"host_firewall_enabled": {
"title": "Estado del firewall",
"rationale": "Comprueba `enable: 1` en `/etc/pve/firewall/cluster.fw` y en el `host.fw` del nodo. Proxmox aplica las reglas del nodo solo mientras el interruptor del centro de datos está activo.",
"summary": {
"bothEnabled": "El firewall está habilitado en el centro de datos y en el nodo",
"datacenterOff": "El firewall está deshabilitado en el centro de datos, así que las reglas del nodo no se aplican",
"nodeOff": "El firewall está habilitado en el centro de datos pero no en este nodo"
}
}
},
"storage": {
"orphaned_volumes": {
"title": "Asignación de volúmenes",
"rationale": "Compara los volúmenes que devuelve `pvesm list` con las configuraciones de invitado existentes. Solo examina almacenamiento no compartido: en almacenamiento compartido un volumen puede pertenecer a un invitado que se ejecuta en otro nodo.",
"summary": {
"none": "No se han encontrado volúmenes huérfanos",
"found": "{count} volumen(es) no pertenecen a ningún invitado existente"
}
}
}
},
"summaryFallback": "No se pudo evaluar la comprobación",
"acceptRisk": {
"action": "Aceptar riesgo",
"revoke": "Volver a activo",
"title": "Aceptar este riesgo",
"reasonLabel": "Motivo",
"reasonHelp": "Obligatorio. Queda registrado junto al autor y la fecha.",
"reasonPlaceholder": "p. ej. Contenedores de laboratorio, sin cobertura a propósito",
"expiryLabel": "Revisar dentro de",
"expiryHelp": "Al cumplirse el plazo el hallazgo vuelve a estado activo.",
"expiryNever": "No caduca",
"expiry90": "90 días",
"expiry180": "180 días",
"expiry365": "1 año",
"cancel": "Cancelar",
"confirm": "Aceptar riesgo"
}
}
}
+194 -18
View File
@@ -45,7 +45,8 @@
"profile": "Profil",
"node": "Nœud",
"admin": "Administrateur",
"menu": "Menu de navigation"
"menu": "Menu de navigation",
"audit": "Audit et rapports"
},
"status": {
"healthy": "En bonne santé",
@@ -72,6 +73,8 @@
"networkErrorWhileSaving": "Erreur réseau lors de l'enregistrement"
},
"overview": {
"metricsTimeout": "La requête de métriques a dépassé 30 secondes. ProxMenux réessaiera automatiquement.",
"metricsCachedWarning": "Les métriques nont pas pu être actualisées. Les données récupérées le {time} sont affichées.",
"loadingTitle": "Chargement de l'aperçu du système...",
"loadingDescription": "Récupération de l'état et des métriques du système",
"topProcessesCpu": "Afficher les principaux processus par processeur",
@@ -882,7 +885,7 @@
"executable": "Exécutable",
"workingDir": "Répertoire de travail",
"started": "Commencé",
"runningFor": "Courir pour"
"runningFor": "En cours d'exécution depuis"
},
"states": {
"running": "En cours d'exécution",
@@ -1030,7 +1033,10 @@
"startOnBoot": "Démarrer au démarrage",
"tags": "balises",
"tagsPlaceholder": "Ajouter une balise…",
"tagsNone": "Aucune balise"
"tagsNone": "Aucune balise",
"mountErrors": {
"configuredButNotMounted": "Configuré mais non monté"
}
},
"logs": {
"header": "Journaux pour {name} (VMID : {vmid})",
@@ -1214,7 +1220,15 @@
"humanWeekly": "hebdomadaire ({day} {time})",
"humanMonthly": "mensuel (jour {day} à {time})",
"humanHourly": "Horaire",
"weekdays": "['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']"
"weekdays": [
"Dimanche",
"Lundi",
"Mardi",
"Mercredi",
"Jeudi",
"Vendredi",
"Samedi"
]
},
"cronChip": {
"detected": "cron hôte détecté",
@@ -1241,6 +1255,35 @@
"osStatusUnavailable": "Impossible de déterminer l’état des mises à jour du système dexploitation.",
"installedByHelperPrefix": "Installé par",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperCommandEditHint": "Si vous modifiez cette commande de lancement, elle sera enregistrée comme commande personnalisée.",
"customOnlineDescription": "Ou utilisez un script en ligne fourni par le développeur :",
"customOnlineExampleNote": "Remplacez lURL dexemple par celle du script officiel de votre application.",
"customExamplesHeading": "Exemples à adapter:",
"updaterChoiceHint": "Choisissez une méthode de mise à jour et enregistrez-la.",
"updaterMethodLabel": "Méthode de mise à jour",
"helperMethod": "Helper-Scripts",
"customMethod": "Commande personnalisée",
"helperMethodHelp": "À propos de Helper-Scripts",
"customMethodHelp": "À propos des commandes personnalisées",
"helperDetectedChoice": "Un programme de mise à jour Helper-Scripts a été détecté pour cette application.",
"helperUnavailableChoice": "Le programme de mise à jour Helper-Scripts sélectionné nest plus disponible.",
"disableUpdater": "Désactiver",
"disableUpdaterConfirm": "Désactiver la méthode de mise à jour de {name} ? Les plans et les programmations conserveront leurs sélections, mais cette application ne sera pas exécutée tant quune méthode naura pas été reconfigurée.",
"noUpdaterSelected": "Vous navez pas sélectionné de méthode de mise à jour.",
"helperMethodDescription": "ProxMenux exécute le programme de mise à jour détecté dans ce LXC et affiche son journal et son résultat. Ce programme est maintenu par Proxmox VE Helper-Scripts.",
"customMethodDescription": "Vous pouvez utiliser le programme de mise à jour de lapplication, un gestionnaire de paquets ou un nouveau binaire. La commande sexécute dans ce LXC avec les droits administrateur.",
"updaterInstructions": "Consultez les instructions du programme de mise à jour.",
"helperDocumentation": "Documentation des mises à jour",
"helperSource": "Script de lapplication",
"customMethodExample": "Adaptez les noms, les chemins et le service à votre application. Seule la commande enregistrée sera exécutée.",
"helperCommandLabel": "Commande dans le LXC",
"helperCommandFallback": "Si wget nest pas disponible, ProxMenux utilise curl -fsSL pour télécharger le même script.",
"customScriptTitle": "Script de mise à jour",
"customScriptDescription": "Exécutez un script de mise à jour de lapplication déjà présent dans le LXC.",
"customPackageTitle": "Paquet Debian/Ubuntu",
"customPackageDescription": "Remplacez my-package par le paquet à mettre à jour.",
"customBinaryTitle": "Binaire",
"customBinaryDescription": "Téléchargez dabord le binaire officiel pour la version et larchitecture appropriées dans /tmp/my-app.new, puis vérifiez-le. Cet exemple conserve une copie du binaire précédent et redémarre son service.",
"helperUpdatesRun": "Installé par Proxmox Helper-Scripts.",
"installedPrefix": "installé",
"upstreamAvailable": "version {version} disponible",
@@ -1564,22 +1607,26 @@
"excludeFromBadgeHelp": "Ne comptez pas cette application dans le badge de mises à jour globales sur la carte de liste LXC.Utile lorsque vous êtes volontairement épinglé à une version spécifique (exigence de suivi, gel de la compatibilité).N'affecte pas l'état de l'onglet Application ni la notification sortante."
},
"statusFilter": {
"ariaLabel": "filtrer les machines virtuelles et les conteneurs par statut",
"ariaLabel": "Filtrer les machines virtuelles et les conteneurs",
"all": "Tous",
"running": "Courir",
"running": "En cours d'exécution",
"stopped": "Arrêté",
"empty": "Aucune machine virtuelle ou conteneur avec le statut \"{status}\""
},
"filters": {
"searchPlaceholder": "rechercher un nom, un identifiant, une étiquette ou une note…",
"searchPlaceholder": "Rechercher par nom, ID, étiquette ou note…",
"searchAriaLabel": "Rechercher des machines virtuelles et des conteneurs",
"clearSearch": "Effacer la recherche",
"updates": "mises à jour",
"resultCount": "{shown} sur {total} machines",
"noMatches": "Aucune machine virtuelle ou LXC ne correspond à votre recherche.",
"noRunning": "Aucune machine virtuelle ou LXC en cours dexécution.",
"noStopped": "Aucune machine virtuelle ou LXC arrêtée.",
"noUpdates": "Non LXC avec les mises à jour disponibles."
"updates": "Mises à jour",
"resultCount": "{shown} sur {total} machines",
"noMatches": "Aucune VM ni LXC ne correspond à la recherche.",
"noRunning": "Aucune VM ni LXC en cours d'exécution.",
"noStopped": "Aucune VM ni LXC arrêtée.",
"noUpdates": "Aucun LXC avec des mises à jour disponibles.",
"typeSynonyms": {
"lxc": "conteneur conteneurs",
"qemu": "vm machine virtuelle machines virtuelles"
}
}
},
"settings": {
@@ -2714,7 +2761,7 @@
"executiveSummary": "Résumé exécutif",
"hardeningAssessment": "Évaluation du durcissement",
"auditOf": "Vérification de",
"running": "courir sur",
"running": "exécuté sur",
"unknownOs": "système d'exploitation inconnu",
"noActionableWarnings": "Aucun avertissement exploitable",
"and": "et",
@@ -4055,14 +4102,14 @@
"jobNameHelpAnd": "et",
"jobNameHelpBefore": "Utilisé dans les noms de minuterie et les journaux. Les caractères autorisés incluent également",
"jobNameLocked": "Le nom du travail est verrouillé après la création.",
"lastRun": "Dernière course",
"lastRunLabel": "Dernière course",
"lastRun": "Dernière exécution",
"lastRunLabel": "Dernière exécution",
"liveLogSize": "en direct · {size}",
"loadFailed": "Impossible de charger les tâches",
"loadingJob": "Chargement du travail...",
"manualOneShot": "manuel / one-shot",
"manualOneShotDescription": "Sauvegarde ponctuelle : capturée au moment du déclenchement. Ne peut pas être réexécuté ou modifié.",
"neverRun": "ne jamais courir",
"neverRun": "jamais exécuté",
"newScheduledJob": "Nouveau travail planifié",
"newScheduledJobDescription": "Créez une tâche de sauvegarde d'hôte récurrente.",
"nextRun": "Prochaine exécution",
@@ -4193,7 +4240,7 @@
"inProgress": "Sauvegarde manuelle en cours",
"oneShotDescription": "Les sauvegardes ponctuelles sont conservées sous forme de tâches gelées afin que vous puissiez inspecter leur journal ultérieurement.",
"reopenLogTitle": "Rouvrir le journal",
"run": "Courir",
"run": "Exécuter",
"runBackup": "Exécuter une sauvegarde",
"runOneShotBackup": "Exécuter une sauvegarde ponctuelle",
"title": "Sauvegarde manuelle",
@@ -4861,5 +4908,134 @@
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
},
"audit": {
"title": "Audit & Report",
"loading": "Loading assessment…",
"run": "Run assessment",
"running": "Assessing…",
"neverRun": "This host has not been assessed yet.",
"lastRun": "Last assessed on {when}",
"stale": "{days} days ago",
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
"noFindings": "No findings match the current filter.",
"showPassing": "Show passing checks",
"hidePassing": "Hide passing checks",
"affectedCount": "{count} affected",
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
"states": {
"fail": "Failed",
"warn": "Warning",
"accepted": "Accepted risk",
"pass": "Passed",
"not_applicable": "Not applicable"
},
"areas": {
"all": "All",
"system": "System",
"storage": "Storage",
"network": "Network",
"security": "Security",
"backup": "Backup",
"guests": "Guests",
"hardware": "Hardware"
},
"detail": {
"why": "Context",
"evidence": "Evidence",
"affected": "Affected",
"acceptedRisk": "Accepted risk"
},
"errors": {
"runFailed": "The assessment could not be started."
},
"checks": {
"backup": {
"guest_coverage": {
"title": "Backup coverage",
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
"summary": {
"noJobs": "No backup job is defined on this node",
"covered": "All {total} guests are covered by a backup job",
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
}
}
},
"system": {
"pending_reboot": {
"title": "Restart state",
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
"summary": {
"none": "No restart is pending",
"pending": "The host has a pending restart"
}
},
"enterprise_repo_without_subscription": {
"title": "Enterprise repository",
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
"summary": {
"notEnabled": "The enterprise repository is not enabled",
"subscribed": "The enterprise repository is backed by a subscription",
"unsubscribed": "The enterprise repository is enabled without an active subscription"
}
}
},
"guests": {
"privileged_containers": {
"title": "Container privileges",
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
"summary": {
"allUnprivileged": "All {total} containers are unprivileged",
"privileged": "{count} of {total} containers run privileged"
}
},
"qemu_without_agent": {
"title": "Guest agent on virtual machines",
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
"summary": {
"allHaveAgent": "All {total} virtual machines declare the guest agent",
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
}
}
},
"security": {
"host_firewall_enabled": {
"title": "Firewall state",
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
"summary": {
"bothEnabled": "The firewall is enabled at datacenter and node level",
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
}
}
},
"storage": {
"orphaned_volumes": {
"title": "Volume assignment",
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
"summary": {
"none": "No orphaned volumes were found",
"found": "{count} volume(s) belong to no existing guest"
}
}
}
},
"summaryFallback": "The check could not be evaluated",
"acceptRisk": {
"action": "Accept risk",
"revoke": "Return to active",
"title": "Accept this risk",
"reasonLabel": "Reason",
"reasonHelp": "Required. It is recorded together with the author and the date.",
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
"expiryLabel": "Review after",
"expiryHelp": "When the period ends the finding becomes active again.",
"expiryNever": "Does not expire",
"expiry90": "90 days",
"expiry180": "180 days",
"expiry365": "1 year",
"cancel": "Cancel",
"confirm": "Accept risk"
}
}
}
+207 -31
View File
@@ -45,7 +45,8 @@
"profile": "Profilo",
"node": "Nodo",
"admin": "Ammin",
"menu": "Menù di navigazione"
"menu": "Menù di navigazione",
"audit": "Audit e report"
},
"status": {
"healthy": "Salutare",
@@ -72,6 +73,8 @@
"networkErrorWhileSaving": "Errore di rete durante il salvataggio"
},
"overview": {
"metricsTimeout": "La richiesta delle metriche ha superato i 30 secondi. ProxMenux riproverà automaticamente.",
"metricsCachedWarning": "Impossibile aggiornare le metriche. Sono visualizzati i dati recuperati il {time}.",
"loadingTitle": "Caricamento panoramica del sistema...",
"loadingDescription": "Recupero dello stato e delle metriche del sistema",
"topProcessesCpu": "Visualizza i processi principali per CPU",
@@ -327,7 +330,7 @@
"statusValues": {
"passed": "Superato",
"failed": "Fallito",
"running": "Corsa",
"running": "In esecuzione",
"aborted": "Abortito",
"unknown": "Sconosciuto"
}
@@ -882,10 +885,10 @@
"executable": "Eseguibile",
"workingDir": "Dir. di lavoro",
"started": "Iniziato",
"runningFor": "Correre per"
"runningFor": "In esecuzione da"
},
"states": {
"running": "Corsa",
"running": "In esecuzione",
"sleeping": "Dormire",
"diskWait": "Attendere il disco",
"zombie": "Zombie",
@@ -905,7 +908,7 @@
"totalCpuAllocated": "CPU totale allocata",
"totalMemory": "Memoria totale",
"totalDisk": "Disco totale",
"running": "corsa",
"running": "in esecuzione",
"stopped": "fermato",
"vms": "VM",
"used": "Usato",
@@ -1030,7 +1033,10 @@
"startOnBoot": "inizia all'avvio",
"tags": "tag",
"tagsPlaceholder": "aggiungi tag…",
"tagsNone": "nessun tag"
"tagsNone": "nessun tag",
"mountErrors": {
"configuredButNotMounted": "Configurato ma non montato"
}
},
"logs": {
"header": "Registri per {name} (VMID: {vmid})",
@@ -1214,7 +1220,15 @@
"humanWeekly": "testo tecnico dell'interfaccia utente per una dashboard di gestione Proxmox.Traduzione: settimanale ({day} {time})",
"humanMonthly": "testo tecnico dell'interfaccia utente per una dashboard di gestione Proxmox.Traduzione: mensile (giorno {day} alle {time})",
"humanHourly": "ogni ora",
"weekdays": "['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato']"
"weekdays": [
"Domenica",
"Lunedì",
"Martedì",
"Mercoledì",
"Giovedì",
"Venerdì",
"Sabato"
]
},
"cronChip": {
"detected": "rilevato il cron dell'host",
@@ -1241,6 +1255,35 @@
"osStatusUnavailable": "Impossibile determinare lo stato degli aggiornamenti del sistema operativo.",
"installedByHelperPrefix": "Installato da",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperCommandEditHint": "Se modifichi questo comando di avvio, verrà salvato come comando personalizzato.",
"customOnlineDescription": "Oppure usa uno script online fornito dallo sviluppatore:",
"customOnlineExampleNote": "Sostituisci lURL di esempio con quello dello script ufficiale della tua applicazione.",
"customExamplesHeading": "Esempi da adattare:",
"updaterChoiceHint": "Scegli e salva un metodo di aggiornamento.",
"updaterMethodLabel": "Metodo di aggiornamento",
"helperMethod": "Helper-Scripts",
"customMethod": "Comando personalizzato",
"helperMethodHelp": "Informazioni su Helper-Scripts",
"customMethodHelp": "Informazioni sui comandi personalizzati",
"helperDetectedChoice": "Rilevato un programma di aggiornamento Helper-Scripts per questa applicazione.",
"helperUnavailableChoice": "Il programma di aggiornamento Helper-Scripts selezionato non è più disponibile.",
"disableUpdater": "Disattiva",
"disableUpdaterConfirm": "Disattivare il metodo di aggiornamento di {name}? I piani e le pianificazioni manterranno le selezioni, ma questa applicazione non verrà eseguita finché non sarà configurato nuovamente un metodo.",
"noUpdaterSelected": "Non hai selezionato un metodo di aggiornamento.",
"helperMethodDescription": "ProxMenux esegue il programma di aggiornamento rilevato in questo LXC e ne mostra il registro e il risultato. Il programma è mantenuto da Proxmox VE Helper-Scripts.",
"customMethodDescription": "Puoi usare il programma di aggiornamento dellapplicazione, un gestore di pacchetti o un nuovo binario. Il comando viene eseguito in questo LXC con privilegi di amministratore.",
"updaterInstructions": "Consulta le istruzioni del programma di aggiornamento.",
"helperDocumentation": "Documentazione degli aggiornamenti",
"helperSource": "Script dellapplicazione",
"customMethodExample": "Adatta i nomi, i percorsi e il servizio alla tua applicazione. Verrà eseguito solo il comando salvato.",
"helperCommandLabel": "Comando allinterno del LXC",
"helperCommandFallback": "Se wget non è disponibile, ProxMenux usa curl -fsSL per scaricare lo stesso script.",
"customScriptTitle": "Script di aggiornamento",
"customScriptDescription": "Esegui uno script di aggiornamento dellapplicazione già presente nel LXC.",
"customPackageTitle": "Pacchetto Debian/Ubuntu",
"customPackageDescription": "Sostituisci my-package con il pacchetto da aggiornare.",
"customBinaryTitle": "Binario",
"customBinaryDescription": "Prima scarica e verifica il binario ufficiale per la versione e larchitettura corrette in /tmp/my-app.new. Questo esempio conserva una copia del binario precedente e riavvia il relativo servizio.",
"helperUpdatesRun": "Installato da Proxmox Helper-Scripts.",
"installedPrefix": "installato",
"upstreamAvailable": "versione {version} disponibile",
@@ -1564,22 +1607,26 @@
"excludeFromBadgeHelp": "non contare questa app nel badge degli aggiornamenti aggregati sulla scheda dell'elenco LXC.Utile quando sei bloccato di proposito su una versione specifica (requisito del tracker, blocco della compatibilità).Non influisce sullo stato della scheda App o sulla notifica in uscita."
},
"statusFilter": {
"ariaLabel": "filtra macchine virtuali e contenitori in base allo stato",
"all": "tutto",
"running": "Correre",
"stopped": "fermato",
"empty": "nessuna macchina virtuale o contenitore con stato \"{status}\""
"ariaLabel": "Filtra macchine virtuali e container",
"all": "Tutti",
"running": "In esecuzione",
"stopped": "Arrestato",
"empty": "Nessuna macchina virtuale o container con stato \"{status}\""
},
"filters": {
"searchPlaceholder": "cerca nome, ID, tag o nota...",
"searchAriaLabel": "cerca macchine virtuali e contenitori",
"clearSearch": "cancella la ricerca",
"searchPlaceholder": "Cerca per nome, ID, tag o nota…",
"searchAriaLabel": "Cerca macchine virtuali e container",
"clearSearch": "Cancella ricerca",
"updates": "Aggiornamenti",
"resultCount": "{shown} di {total} macchine",
"noMatches": "nessuna VM o LXC corrisponde alla tua ricerca.",
"noRunning": "nessuna VM o LXC in esecuzione.",
"noStopped": "nessuna VM o LXC interrotta.",
"noUpdates": "No LXC con aggiornamenti disponibili."
"noMatches": "Nessuna VM o LXC corrisponde alla ricerca.",
"noRunning": "Nessuna VM o LXC in esecuzione.",
"noStopped": "Nessuna VM o LXC arrestata.",
"noUpdates": "Nessun LXC con aggiornamenti disponibili.",
"typeSynonyms": {
"lxc": "contenitore contenitori container",
"qemu": "vm macchina virtuale macchine virtuali"
}
}
},
"settings": {
@@ -2714,7 +2761,7 @@
"executiveSummary": "Sintesi",
"hardeningAssessment": "Valutazione dell'indurimento",
"auditOf": "Revisione di",
"running": "correndo",
"running": "in esecuzione su",
"unknownOs": "sistema operativo sconosciuto",
"noActionableWarnings": "Nessun avviso utilizzabile",
"and": "E",
@@ -3577,7 +3624,7 @@
"reinstallViaPostInstall": "Reinstallare tramite ProxMenux dopo l'installazione: {label}",
"noUpdatesAvailable": "Nessun aggiornamento disponibile",
"none": "nessuno",
"running": "corsa",
"running": "in esecuzione",
"stopped": "fermato",
"unused": "inutilizzato",
"unbound": "non vincolato",
@@ -3987,7 +4034,7 @@
"name": "Nome",
"nameLabel": "Nome",
"newRecoveryPassphrase": "Nuova passphrase di ripristino",
"nextRun": "Prossima corsa",
"nextRun": "Prossima esecuzione",
"normalizedLabel": "Normalizzato",
"onCalendarExpression": "Espressione OnCalendar",
"ownerLabel": "Proprietario",
@@ -4055,19 +4102,19 @@
"jobNameHelpAnd": "E",
"jobNameHelpBefore": "Utilizzato nei nomi e nei registri dei timer. Sono inclusi anche i caratteri consentiti",
"jobNameLocked": "Il nome del lavoro viene bloccato dopo la creazione.",
"lastRun": "Ultima corsa",
"lastRunLabel": "Ultima corsa",
"lastRun": "Ultima esecuzione",
"lastRunLabel": "Ultima esecuzione",
"liveLogSize": "dal vivo · {size}",
"loadFailed": "Impossibile caricare i lavori",
"loadingJob": "Caricamento lavoro...",
"manualOneShot": "manuale/one-shot",
"manualOneShotDescription": "Backup one-shot: acquisito al momento dell'attivazione. Non può essere rieseguito o modificato.",
"neverRun": "non correre mai",
"neverRun": "mai eseguito",
"newScheduledJob": "Nuovo lavoro programmato",
"newScheduledJobDescription": "Creare un processo di backup dell'host ricorrente.",
"nextRun": "Prossima corsa",
"nextRunLabel": "Prossima corsa",
"nextRunTitle": "Prossima corsa programmata",
"nextRun": "Prossima esecuzione",
"nextRunLabel": "Prossima esecuzione",
"nextRunTitle": "Prossima esecuzione pianificata",
"noCompatiblePveJob": "Nessun processo di backup PVE compatibile trovato",
"noCompatiblePveJobDescriptionAfter": "Primo.",
"noCompatiblePveJobDescriptionBefore": "Crea o abilita un processo di backup PVE per",
@@ -4193,7 +4240,7 @@
"inProgress": "Backup manuale in corso",
"oneShotDescription": "I backup one-shot vengono conservati come processi congelati in modo da poterne controllare il registro in un secondo momento.",
"reopenLogTitle": "Riapri registro",
"run": "Correre",
"run": "Esegui",
"runBackup": "Esegui il backup",
"runOneShotBackup": "Esegui il backup one-shot",
"title": "Backup manuale",
@@ -4369,7 +4416,7 @@
"manual": "manuale",
"manualOneShot": "manuale/one-shot",
"ok": "OK",
"running": "corsa",
"running": "in esecuzione",
"scheduled": "programmato"
},
"taskStates": {
@@ -4378,7 +4425,7 @@
"packing": "imballaggio",
"queued": "in coda",
"restoring": "ripristino",
"running": "corsa"
"running": "in esecuzione"
},
"usb": {
"description": "Monta le unità USB in modo che possano essere scelte come destinazione locale o Borg. Le unità che già disponevano di un filesystem possono essere rimontate così come sono; è possibile cancellare e formattare le unità raw (nessuna tabella delle partizioni).",
@@ -4861,5 +4908,134 @@
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
},
"audit": {
"title": "Audit & Report",
"loading": "Loading assessment…",
"run": "Run assessment",
"running": "Assessing…",
"neverRun": "This host has not been assessed yet.",
"lastRun": "Last assessed on {when}",
"stale": "{days} days ago",
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
"noFindings": "No findings match the current filter.",
"showPassing": "Show passing checks",
"hidePassing": "Hide passing checks",
"affectedCount": "{count} affected",
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
"states": {
"fail": "Failed",
"warn": "Warning",
"accepted": "Accepted risk",
"pass": "Passed",
"not_applicable": "Not applicable"
},
"areas": {
"all": "All",
"system": "System",
"storage": "Storage",
"network": "Network",
"security": "Security",
"backup": "Backup",
"guests": "Guests",
"hardware": "Hardware"
},
"detail": {
"why": "Context",
"evidence": "Evidence",
"affected": "Affected",
"acceptedRisk": "Accepted risk"
},
"errors": {
"runFailed": "The assessment could not be started."
},
"checks": {
"backup": {
"guest_coverage": {
"title": "Backup coverage",
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
"summary": {
"noJobs": "No backup job is defined on this node",
"covered": "All {total} guests are covered by a backup job",
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
}
}
},
"system": {
"pending_reboot": {
"title": "Restart state",
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
"summary": {
"none": "No restart is pending",
"pending": "The host has a pending restart"
}
},
"enterprise_repo_without_subscription": {
"title": "Enterprise repository",
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
"summary": {
"notEnabled": "The enterprise repository is not enabled",
"subscribed": "The enterprise repository is backed by a subscription",
"unsubscribed": "The enterprise repository is enabled without an active subscription"
}
}
},
"guests": {
"privileged_containers": {
"title": "Container privileges",
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
"summary": {
"allUnprivileged": "All {total} containers are unprivileged",
"privileged": "{count} of {total} containers run privileged"
}
},
"qemu_without_agent": {
"title": "Guest agent on virtual machines",
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
"summary": {
"allHaveAgent": "All {total} virtual machines declare the guest agent",
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
}
}
},
"security": {
"host_firewall_enabled": {
"title": "Firewall state",
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
"summary": {
"bothEnabled": "The firewall is enabled at datacenter and node level",
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
}
}
},
"storage": {
"orphaned_volumes": {
"title": "Volume assignment",
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
"summary": {
"none": "No orphaned volumes were found",
"found": "{count} volume(s) belong to no existing guest"
}
}
}
},
"summaryFallback": "The check could not be evaluated",
"acceptRisk": {
"action": "Accept risk",
"revoke": "Return to active",
"title": "Accept this risk",
"reasonLabel": "Reason",
"reasonHelp": "Required. It is recorded together with the author and the date.",
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
"expiryLabel": "Review after",
"expiryHelp": "When the period ends the finding becomes active again.",
"expiryNever": "Does not expire",
"expiry90": "90 days",
"expiry180": "180 days",
"expiry365": "1 year",
"cancel": "Cancel",
"confirm": "Accept risk"
}
}
}
+197 -21
View File
@@ -45,7 +45,8 @@
"profile": "Perfil",
"node": "Nó",
"admin": "Administrador",
"menu": "Menu de navegação"
"menu": "Menu de navegação",
"audit": "Auditoria e relatórios"
},
"status": {
"healthy": "Saudável",
@@ -72,6 +73,8 @@
"networkErrorWhileSaving": "Erro de rede ao salvar"
},
"overview": {
"metricsTimeout": "A consulta de métricas excedeu 30 segundos. O ProxMenux tentará novamente automaticamente.",
"metricsCachedWarning": "Não foi possível atualizar as métricas. São apresentados os dados obtidos em {time}.",
"loadingTitle": "Carregando visão geral do sistema...",
"loadingDescription": "Buscando status e métricas do sistema",
"topProcessesCpu": "Veja os principais processos por CPU",
@@ -327,7 +330,7 @@
"statusValues": {
"passed": "Aprovado",
"failed": "Fracassado",
"running": "Correndo",
"running": "Em execução",
"aborted": "Abortado",
"unknown": "Desconhecido"
}
@@ -882,10 +885,10 @@
"executable": "Executável",
"workingDir": "Diretório de trabalho",
"started": "Iniciado",
"runningFor": "Correndo para"
"runningFor": "Em execução há"
},
"states": {
"running": "Correndo",
"running": "Em execução",
"sleeping": "Dormindo",
"diskWait": "Espera de disco",
"zombie": "Zumbi",
@@ -905,7 +908,7 @@
"totalCpuAllocated": "Total de CPU alocada",
"totalMemory": "Memória total",
"totalDisk": "Disco total",
"running": "correndo",
"running": "em execução",
"stopped": "parou",
"vms": "VMs",
"used": "Usado",
@@ -1030,7 +1033,10 @@
"startOnBoot": "Iniciar na inicialização",
"tags": "Etiquetas",
"tagsPlaceholder": "Adicionar tag…",
"tagsNone": "sem tags"
"tagsNone": "sem tags",
"mountErrors": {
"configuredButNotMounted": "Configurado mas não montado"
}
},
"logs": {
"header": "Registros para {name} (VMID: {vmid})",
@@ -1214,7 +1220,15 @@
"humanWeekly": "Semanal ({day} {time})",
"humanMonthly": "Mensalmente (dia {day} às {time})",
"humanHourly": "de hora em hora",
"weekdays": "['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado']"
"weekdays": [
"Domingo",
"Segunda-feira",
"Terça-feira",
"Quarta-feira",
"Quinta-feira",
"Sexta-feira",
"Sábado"
]
},
"cronChip": {
"detected": "cron do host detectado",
@@ -1241,6 +1255,35 @@
"osStatusUnavailable": "Não foi possível determinar o estado das atualizações do sistema operativo.",
"installedByHelperPrefix": "Instalado por",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperCommandEditHint": "Se modificar este comando de arranque, será guardado como comando personalizado.",
"customOnlineDescription": "Ou utilize um script online fornecido pelo programador:",
"customOnlineExampleNote": "Substitua o URL de exemplo pelo do script oficial da sua aplicação.",
"customExamplesHeading": "Exemplos para adaptar:",
"updaterChoiceHint": "Escolha e guarde um método de atualização.",
"updaterMethodLabel": "Método de atualização",
"helperMethod": "Helper-Scripts",
"customMethod": "Comando personalizado",
"helperMethodHelp": "Sobre Helper-Scripts",
"customMethodHelp": "Sobre comandos personalizados",
"helperDetectedChoice": "Foi detetado um atualizador Helper-Scripts para esta aplicação.",
"helperUnavailableChoice": "O atualizador Helper-Scripts selecionado já não está disponível.",
"disableUpdater": "Desativar",
"disableUpdaterConfirm": "Desativar o método de atualização de {name}? Os planos e agendamentos manterão as seleções, mas esta aplicação não será executada até configurar novamente um método.",
"noUpdaterSelected": "Não selecionou um método de atualização.",
"helperMethodDescription": "O ProxMenux executa o atualizador detetado dentro deste LXC e mostra o registo e o resultado. Este é mantido pelo Proxmox VE Helper-Scripts.",
"customMethodDescription": "Pode utilizar o atualizador da aplicação, um gestor de pacotes ou um novo binário. O comando é executado dentro deste LXC com privilégios de administrador.",
"updaterInstructions": "Consulte as instruções do atualizador.",
"helperDocumentation": "Documentação de atualização",
"helperSource": "Script da aplicação",
"customMethodExample": "Adapte os nomes, os caminhos e o serviço à sua aplicação. Só será executado o comando que guardar.",
"helperCommandLabel": "Comando dentro do LXC",
"helperCommandFallback": "Se o wget não estiver disponível, o ProxMenux utiliza curl -fsSL para transferir o mesmo script.",
"customScriptTitle": "Script de atualização",
"customScriptDescription": "Execute um script de atualização da aplicação já existente no LXC.",
"customPackageTitle": "Pacote Debian/Ubuntu",
"customPackageDescription": "Substitua my-package pelo pacote que pretende atualizar.",
"customBinaryTitle": "Binário",
"customBinaryDescription": "Primeiro transfira e verifique o binário oficial da versão e arquitetura corretas em /tmp/my-app.new. Este exemplo conserva uma cópia do binário anterior e reinicia o respetivo serviço.",
"helperUpdatesRun": "Instalado pelo Proxmox Helper-Scripts.",
"installedPrefix": "instalado",
"upstreamAvailable": "versão {version} disponível",
@@ -1564,22 +1607,26 @@
"excludeFromBadgeHelp": "não conte este aplicativo no selo de atualizações agregadas no cartão de lista LXC.Útil quando você está fixado em uma versão específica propositalmente (requisito do rastreador, congelamento de compatibilidade).Não afeta o próprio estado da guia Aplicativo ou a notificação de saída."
},
"statusFilter": {
"ariaLabel": "Filtre máquinas virtuais e contêineres por status",
"ariaLabel": "Filtrar máquinas virtuais e contêineres",
"all": "Todos",
"running": "Correndo",
"running": "Em execução",
"stopped": "Parado",
"empty": "Nenhuma máquina virtual ou contêiner com status \"{status}\""
},
"filters": {
"searchPlaceholder": "pesquise nome, ID, tag ou nota…",
"searchAriaLabel": "Pesquise máquinas virtuais e contêineres",
"clearSearch": "Limpar pesquisa",
"searchPlaceholder": "Buscar por nome, ID, tag ou nota…",
"searchAriaLabel": "Buscar máquinas virtuais e contêineres",
"clearSearch": "Limpar busca",
"updates": "Atualizações",
"resultCount": "{shown} de {total} máquinas",
"noMatches": "Nenhuma VM ou LXC corresponde à sua pesquisa.",
"noRunning": "Não há VMs ou LXCs em execução.",
"noStopped": "Não há VMs ou LXCs parados.",
"noUpdates": "Não LXC com atualizações disponíveis."
"noMatches": "Nenhuma VM ou LXC corresponde à busca.",
"noRunning": "Nenhuma VM ou LXC em execução.",
"noStopped": "Nenhuma VM ou LXC parada.",
"noUpdates": "Nenhum LXC com atualizações disponíveis.",
"typeSynonyms": {
"lxc": "contêiner contêineres contentor contentores",
"qemu": "vm máquina virtual máquinas virtuais"
}
}
},
"settings": {
@@ -2714,7 +2761,7 @@
"executiveSummary": "Sumário executivo",
"hardeningAssessment": "Avaliação de endurecimento",
"auditOf": "Auditoria de",
"running": "correndo",
"running": "em execução em",
"unknownOs": "SO desconhecido",
"noActionableWarnings": "Nenhum aviso acionável",
"and": "e",
@@ -3577,7 +3624,7 @@
"reinstallViaPostInstall": "Reinstale via ProxMenux pós-instalação: {label}",
"noUpdatesAvailable": "Nenhuma atualização disponível",
"none": "nenhum",
"running": "correndo",
"running": "em execução",
"stopped": "parou",
"unused": "não utilizado",
"unbound": "não vinculado",
@@ -4193,7 +4240,7 @@
"inProgress": "Backup manual em andamento",
"oneShotDescription": "Os backups únicos são mantidos como trabalhos congelados para que você possa inspecionar seus logs mais tarde.",
"reopenLogTitle": "Reabrir registro",
"run": "Correr",
"run": "Executar",
"runBackup": "Executar backup",
"runOneShotBackup": "Execute o backup único",
"title": "Backup manual",
@@ -4369,7 +4416,7 @@
"manual": "manual",
"manualOneShot": "manual / único",
"ok": "OK",
"running": "correndo",
"running": "em execução",
"scheduled": "agendado"
},
"taskStates": {
@@ -4378,7 +4425,7 @@
"packing": "embalagem",
"queued": "na fila",
"restoring": "restaurando",
"running": "correndo"
"running": "em execução"
},
"usb": {
"description": "Monte unidades USB para que possam ser escolhidas como alvo local ou Borg. Unidades que já possuem um sistema de arquivos podem ser remontadas como estão; unidades brutas (sem tabela de partição) podem ser apagadas e formatadas para",
@@ -4861,5 +4908,134 @@
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
},
"audit": {
"title": "Audit & Report",
"loading": "Loading assessment…",
"run": "Run assessment",
"running": "Assessing…",
"neverRun": "This host has not been assessed yet.",
"lastRun": "Last assessed on {when}",
"stale": "{days} days ago",
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
"noFindings": "No findings match the current filter.",
"showPassing": "Show passing checks",
"hidePassing": "Hide passing checks",
"affectedCount": "{count} affected",
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
"states": {
"fail": "Failed",
"warn": "Warning",
"accepted": "Accepted risk",
"pass": "Passed",
"not_applicable": "Not applicable"
},
"areas": {
"all": "All",
"system": "System",
"storage": "Storage",
"network": "Network",
"security": "Security",
"backup": "Backup",
"guests": "Guests",
"hardware": "Hardware"
},
"detail": {
"why": "Context",
"evidence": "Evidence",
"affected": "Affected",
"acceptedRisk": "Accepted risk"
},
"errors": {
"runFailed": "The assessment could not be started."
},
"checks": {
"backup": {
"guest_coverage": {
"title": "Backup coverage",
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
"summary": {
"noJobs": "No backup job is defined on this node",
"covered": "All {total} guests are covered by a backup job",
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
}
}
},
"system": {
"pending_reboot": {
"title": "Restart state",
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
"summary": {
"none": "No restart is pending",
"pending": "The host has a pending restart"
}
},
"enterprise_repo_without_subscription": {
"title": "Enterprise repository",
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
"summary": {
"notEnabled": "The enterprise repository is not enabled",
"subscribed": "The enterprise repository is backed by a subscription",
"unsubscribed": "The enterprise repository is enabled without an active subscription"
}
}
},
"guests": {
"privileged_containers": {
"title": "Container privileges",
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
"summary": {
"allUnprivileged": "All {total} containers are unprivileged",
"privileged": "{count} of {total} containers run privileged"
}
},
"qemu_without_agent": {
"title": "Guest agent on virtual machines",
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
"summary": {
"allHaveAgent": "All {total} virtual machines declare the guest agent",
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
}
}
},
"security": {
"host_firewall_enabled": {
"title": "Firewall state",
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
"summary": {
"bothEnabled": "The firewall is enabled at datacenter and node level",
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
}
}
},
"storage": {
"orphaned_volumes": {
"title": "Volume assignment",
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
"summary": {
"none": "No orphaned volumes were found",
"found": "{count} volume(s) belong to no existing guest"
}
}
}
},
"summaryFallback": "The check could not be evaluated",
"acceptRisk": {
"action": "Accept risk",
"revoke": "Return to active",
"title": "Accept this risk",
"reasonLabel": "Reason",
"reasonHelp": "Required. It is recorded together with the author and the date.",
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
"expiryLabel": "Review after",
"expiryHelp": "When the period ends the finding becomes active again.",
"expiryNever": "Does not expire",
"expiry90": "90 days",
"expiry180": "180 days",
"expiry365": "1 year",
"cancel": "Cancel",
"confirm": "Accept risk"
}
}
}
+179 -3
View File
@@ -44,7 +44,8 @@
"profile": "Profil",
"node": "Server",
"admin": "Správa",
"menu": "Navigačné menu"
"menu": "Navigačné menu",
"audit": "Audit a správy"
},
"status": {
"healthy": "V poriadku",
@@ -71,6 +72,8 @@
"networkErrorWhileSaving": "Chyba siete pri ukladaní"
},
"overview": {
"metricsTimeout": "Načítanie metrík trvalo dlhšie ako 30 sekúnd. ProxMenux to automaticky skúsi znova.",
"metricsCachedWarning": "Metriky sa nepodarilo obnoviť. Zobrazujú sa údaje načítané {time}.",
"loadingTitle": "Načítava sa prehľad systému...",
"loadingDescription": "Zisťujem stav servera a základné údaje",
"topProcessesCpu": "Zobraziť procesy s najvyšším využitím CPU",
@@ -932,7 +935,11 @@
"noMatches": "Žiadne VM ani LXC nezodpovedajú hľadaniu.",
"noRunning": "Nie sú tu žiadne spustené VM ani LXC.",
"noStopped": "Nie sú tu žiadne vypnuté VM ani LXC.",
"noUpdates": "Žiadne LXC nemá dostupné aktualizácie."
"noUpdates": "Žiadne LXC nemá dostupné aktualizácie.",
"typeSynonyms": {
"lxc": "kontajner kontajnery",
"qemu": "vm virtuálny stroj virtuálne stroje"
}
},
"uptime": "Beží: {uptime}",
"cpuUsage": "Využitie CPU",
@@ -1044,6 +1051,9 @@
"stopped": "vypnuté",
"mounted": "pripojené"
},
"mountErrors": {
"configuredButNotMounted": "Nastavené, ale nepripojené"
},
"startOnBoot": "Spúšťať pri štarte",
"tags": "Značky",
"tagsPlaceholder": "Pridať značku…",
@@ -1231,7 +1241,15 @@
"humanWeekly": "Týždenne ({day} o {time})",
"humanMonthly": "Mesačne ({day}. deň o {time})",
"humanHourly": "Každú hodinu",
"weekdays": ["nedeľa", "pondelok", "utorok", "streda", "štvrtok", "piatok", "sobota"]
"weekdays": [
"nedeľa",
"pondelok",
"utorok",
"streda",
"štvrtok",
"piatok",
"sobota"
]
},
"cronChip": {
"detected": "zistený cron na serveri",
@@ -1258,6 +1276,35 @@
"osStatusUnavailable": "Stav aktualizácií operačného systému sa nepodarilo zistiť.",
"installedByHelperPrefix": "Nainštalované cez",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperCommandEditHint": "Ak upravíte tento spúšťací príkaz, uloží sa ako vlastný príkaz.",
"customOnlineDescription": "Alebo použite online skript poskytnutý vývojárom:",
"customOnlineExampleNote": "Nahraďte vzorovú URL adresu adresou oficiálneho skriptu vašej aplikácie.",
"customExamplesHeading": "Príklady na prispôsobenie:",
"updaterChoiceHint": "Vyberte a uložte spôsob aktualizácie.",
"updaterMethodLabel": "Spôsob aktualizácie",
"helperMethod": "Helper-Scripts",
"customMethod": "Vlastný príkaz",
"helperMethodHelp": "O Helper-Scripts",
"customMethodHelp": "O vlastných príkazoch",
"helperDetectedChoice": "Pre túto aplikáciu bol zistený aktualizátor Helper-Scripts.",
"helperUnavailableChoice": "Vybraný aktualizátor Helper-Scripts už nie je dostupný.",
"disableUpdater": "Deaktivovať",
"disableUpdaterConfirm": "Deaktivovať spôsob aktualizácie pre {name}? Uložené plány a naplánované úlohy si zachovajú svoj výber, ale táto aplikácia sa nespustí, kým znova nenastavíte spôsob aktualizácie.",
"noUpdaterSelected": "Nevybrali ste spôsob aktualizácie.",
"helperMethodDescription": "ProxMenux spustí zistený aktualizátor v tomto LXC a zobrazí jeho protokol a výsledok. Aktualizátor spravuje Proxmox VE Helper-Scripts.",
"customMethodDescription": "Môžete použiť aktualizátor aplikácie, správcu balíkov alebo nový binárny súbor. Príkaz sa spustí v tomto LXC s oprávneniami správcu.",
"updaterInstructions": "Prečítajte si pokyny aktualizátora.",
"helperDocumentation": "Dokumentácia aktualizácií",
"helperSource": "Skript aplikácie",
"customMethodExample": "Prispôsobte názvy, cesty a službu svojej aplikácii. Spustí sa iba príkaz, ktorý uložíte.",
"helperCommandLabel": "Príkaz v LXC",
"helperCommandFallback": "Ak wget nie je dostupný, ProxMenux použije curl -fsSL na stiahnutie rovnakého skriptu.",
"customScriptTitle": "Aktualizačný skript",
"customScriptDescription": "Spustite aktualizačný skript aplikácie, ktorý už existuje v LXC.",
"customPackageTitle": "Balík Debian/Ubuntu",
"customPackageDescription": "Nahraďte my-package balíkom, ktorý chcete aktualizovať.",
"customBinaryTitle": "Binárny súbor",
"customBinaryDescription": "Najprv stiahnite a overte oficiálny binárny súbor správnej verzie a architektúry v /tmp/my-app.new. Tento príklad zachová kópiu predchádzajúceho binárneho súboru a reštartuje príslušnú službu.",
"helperUpdatesRun": "Inštalované pomocou Proxmox Helper-Scripts.",
"installedPrefix": "nainštalovaná verzia",
"upstreamAvailable": "dostupná verzia {version}",
@@ -4927,5 +4974,134 @@
"customLinkDelete": "Odstrániť",
"customLinkSaveError": "Odkaz sa nepodarilo uložiť",
"customLinkDeleteError": "Odkaz sa nepodarilo odstrániť"
},
"audit": {
"title": "Audit & Report",
"loading": "Loading assessment…",
"run": "Run assessment",
"running": "Assessing…",
"neverRun": "This host has not been assessed yet.",
"lastRun": "Last assessed on {when}",
"stale": "{days} days ago",
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
"noFindings": "No findings match the current filter.",
"showPassing": "Show passing checks",
"hidePassing": "Hide passing checks",
"affectedCount": "{count} affected",
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
"states": {
"fail": "Failed",
"warn": "Warning",
"accepted": "Accepted risk",
"pass": "Passed",
"not_applicable": "Not applicable"
},
"areas": {
"all": "All",
"system": "System",
"storage": "Storage",
"network": "Network",
"security": "Security",
"backup": "Backup",
"guests": "Guests",
"hardware": "Hardware"
},
"detail": {
"why": "Context",
"evidence": "Evidence",
"affected": "Affected",
"acceptedRisk": "Accepted risk"
},
"errors": {
"runFailed": "The assessment could not be started."
},
"checks": {
"backup": {
"guest_coverage": {
"title": "Backup coverage",
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
"summary": {
"noJobs": "No backup job is defined on this node",
"covered": "All {total} guests are covered by a backup job",
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
}
}
},
"system": {
"pending_reboot": {
"title": "Restart state",
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
"summary": {
"none": "No restart is pending",
"pending": "The host has a pending restart"
}
},
"enterprise_repo_without_subscription": {
"title": "Enterprise repository",
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
"summary": {
"notEnabled": "The enterprise repository is not enabled",
"subscribed": "The enterprise repository is backed by a subscription",
"unsubscribed": "The enterprise repository is enabled without an active subscription"
}
}
},
"guests": {
"privileged_containers": {
"title": "Container privileges",
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
"summary": {
"allUnprivileged": "All {total} containers are unprivileged",
"privileged": "{count} of {total} containers run privileged"
}
},
"qemu_without_agent": {
"title": "Guest agent on virtual machines",
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
"summary": {
"allHaveAgent": "All {total} virtual machines declare the guest agent",
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
}
}
},
"security": {
"host_firewall_enabled": {
"title": "Firewall state",
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
"summary": {
"bothEnabled": "The firewall is enabled at datacenter and node level",
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
}
}
},
"storage": {
"orphaned_volumes": {
"title": "Volume assignment",
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
"summary": {
"none": "No orphaned volumes were found",
"found": "{count} volume(s) belong to no existing guest"
}
}
}
},
"summaryFallback": "The check could not be evaluated",
"acceptRisk": {
"action": "Accept risk",
"revoke": "Return to active",
"title": "Accept this risk",
"reasonLabel": "Reason",
"reasonHelp": "Required. It is recorded together with the author and the date.",
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
"expiryLabel": "Review after",
"expiryHelp": "When the period ends the finding becomes active again.",
"expiryNever": "Does not expire",
"expiry90": "90 days",
"expiry180": "180 days",
"expiry365": "1 year",
"cancel": "Cancel",
"confirm": "Accept risk"
}
}
}
+189 -13
View File
@@ -45,7 +45,8 @@
"profile": "Profil",
"node": "Nod",
"admin": "Administration",
"menu": "Navigationsmeny"
"menu": "Navigationsmeny",
"audit": "Granskning och rapporter"
},
"status": {
"healthy": "Hälsosam",
@@ -72,6 +73,8 @@
"networkErrorWhileSaving": "Nätverksfel vid lagring"
},
"overview": {
"metricsTimeout": "Mätvärdesfrågan tog längre än 30 sekunder. ProxMenux försöker igen automatiskt.",
"metricsCachedWarning": "Mätvärdena kunde inte uppdateras. Data hämtade {time} visas.",
"loadingTitle": "Laddar systemöversikt...",
"loadingDescription": "Hämtar systemstatus och mätvärden",
"topProcessesCpu": "Visa toppprocesser efter CPU",
@@ -1030,7 +1033,10 @@
"startOnBoot": "Börja vid start",
"tags": "Taggar",
"tagsPlaceholder": "Lägg till tagg...",
"tagsNone": "Inga taggar"
"tagsNone": "Inga taggar",
"mountErrors": {
"configuredButNotMounted": "Konfigurerad men inte monterad"
}
},
"logs": {
"header": "Loggar för {name} (VMID: {vmid})",
@@ -1214,7 +1220,15 @@
"humanWeekly": "Varje vecka ({day} {time})",
"humanMonthly": "Varje månad (dag {day} kl. {time})",
"humanHourly": "Varje timme",
"weekdays": "['söndag', 'måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag']"
"weekdays": [
"söndag",
"måndag",
"tisdag",
"onsdag",
"torsdag",
"fredag",
"lördag"
]
},
"cronChip": {
"detected": "host cron upptäckt",
@@ -1241,6 +1255,35 @@
"osStatusUnavailable": "Statusen för operativsystemets uppdateringar kunde inte fastställas.",
"installedByHelperPrefix": "Installerad av",
"helperScriptsName": "Proxmox VE Helper-Scripts",
"helperCommandEditHint": "Om du ändrar det här startkommandot sparas det som ett eget kommando.",
"customOnlineDescription": "Eller använd ett onlineskript från utvecklaren:",
"customOnlineExampleNote": "Ersätt exempeladressen med adressen till appens officiella skript.",
"customExamplesHeading": "Exempel att anpassa:",
"updaterChoiceHint": "Välj och spara en uppdateringsmetod.",
"updaterMethodLabel": "Uppdateringsmetod",
"helperMethod": "Helper-Scripts",
"customMethod": "Eget kommando",
"helperMethodHelp": "Om Helper-Scripts",
"customMethodHelp": "Om egna kommandon",
"helperDetectedChoice": "En Helper-Scripts-uppdaterare har identifierats för den här appen.",
"helperUnavailableChoice": "Den valda Helper-Scripts-uppdateraren är inte längre tillgänglig.",
"disableUpdater": "Inaktivera",
"disableUpdaterConfirm": "Inaktivera uppdateringsmetoden för {name}? Sparade planer och scheman behåller sina val, men den här appen körs inte förrän en metod har konfigurerats igen.",
"noUpdaterSelected": "Du har inte valt någon uppdateringsmetod.",
"helperMethodDescription": "ProxMenux kör den identifierade uppdateraren i denna LXC och visar dess logg och resultat. Uppdateraren underhålls av Proxmox VE Helper-Scripts.",
"customMethodDescription": "Du kan använda appens egen uppdaterare, en pakethanterare eller en ny binärfil. Kommandot körs i denna LXC med administratörsbehörighet.",
"updaterInstructions": "Läs uppdaterarens anvisningar.",
"helperDocumentation": "Uppdateringsdokumentation",
"helperSource": "Appens skript",
"customMethodExample": "Anpassa namn, sökvägar och tjänst till din app. Endast kommandot du sparar kommer att köras.",
"helperCommandLabel": "Kommando i LXC",
"helperCommandFallback": "Om wget inte är tillgängligt använder ProxMenux curl -fsSL för att hämta samma skript.",
"customScriptTitle": "Uppdateringsskript",
"customScriptDescription": "Kör ett uppdateringsskript för appen som redan finns i LXC.",
"customPackageTitle": "Debian/Ubuntu-paket",
"customPackageDescription": "Ersätt my-package med paketet du vill uppdatera.",
"customBinaryTitle": "Binärfil",
"customBinaryDescription": "Hämta och verifiera först den officiella binärfilen för rätt version och arkitektur på /tmp/my-app.new. Det här exemplet sparar en kopia av den tidigare binärfilen och startar om dess tjänst.",
"helperUpdatesRun": "Installerad av Proxmox Helper-Scripts.",
"installedPrefix": "installerat",
"upstreamAvailable": "version {version} tillgänglig",
@@ -1564,22 +1607,26 @@
"excludeFromBadgeHelp": "Räkna inte den här appen i det samlade uppdateringsmärket på LXC-listkortet.Användbart när du är fäst till en specifik version med avsikt (spårningskrav, frysning av kompatibilitet).Påverkar inte appflikens eget tillstånd eller det utgående meddelandet."
},
"statusFilter": {
"ariaLabel": "Filtrera virtuella maskiner och behållare efter status",
"ariaLabel": "Filtrera virtuella maskiner och containrar",
"all": "Alla",
"running": "Löpning",
"running": "Körs",
"stopped": "Stoppad",
"empty": "Inga virtuella maskiner eller behållare med status \"{status}\""
"empty": "Inga virtuella maskiner eller containrar med status \"{status}\""
},
"filters": {
"searchPlaceholder": "Sök efter namn, ID, tagg eller anteckning...",
"searchAriaLabel": "Sök i virtuella maskiner och behållare",
"searchPlaceholder": "Sök namn, ID, tagg eller anteckning",
"searchAriaLabel": "Sök virtuella maskiner och containrar",
"clearSearch": "Rensa sökning",
"updates": "Uppdateringar",
"resultCount": "{shown} av {total} maskiner",
"noMatches": "Inga virtuella datorer eller LXC:er matchar din sökning.",
"noRunning": "Inga virtuella datorer eller LXC:er som körs.",
"noStopped": "Inga stoppade virtuella datorer eller LXC:er.",
"noUpdates": "Inga LXC med tillgängliga uppdateringar."
"noMatches": "Inga VM:ar eller LXC:er matchar sökningen.",
"noRunning": "Inga VM:ar eller LXC:er körs.",
"noStopped": "Inga stoppade VM:ar eller LXC:er.",
"noUpdates": "Inga LXC:er har tillgängliga uppdateringar.",
"typeSynonyms": {
"lxc": "container containrar",
"qemu": "vm virtuell maskin virtuella maskiner"
}
}
},
"settings": {
@@ -4063,7 +4110,7 @@
"loadingJob": "Laddar jobb...",
"manualOneShot": "manuell / one-shot",
"manualOneShotDescription": "Säkerhetskopiering i ett skott — fångad vid tidpunkten för utlösningen. Kan inte köras om eller redigeras.",
"neverRun": "aldrig springa",
"neverRun": "aldrig körd",
"newScheduledJob": "Nytt schemalagt jobb",
"newScheduledJobDescription": "Skapa ett återkommande värdbackupjobb.",
"nextRun": "Nästa körning",
@@ -4862,5 +4909,134 @@
"customLinkDelete": "Delete",
"customLinkSaveError": "Save failed",
"customLinkDeleteError": "Delete failed"
},
"audit": {
"title": "Audit & Report",
"loading": "Loading assessment…",
"run": "Run assessment",
"running": "Assessing…",
"neverRun": "This host has not been assessed yet.",
"lastRun": "Last assessed on {when}",
"stale": "{days} days ago",
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
"noFindings": "No findings match the current filter.",
"showPassing": "Show passing checks",
"hidePassing": "Hide passing checks",
"affectedCount": "{count} affected",
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
"states": {
"fail": "Failed",
"warn": "Warning",
"accepted": "Accepted risk",
"pass": "Passed",
"not_applicable": "Not applicable"
},
"areas": {
"all": "All",
"system": "System",
"storage": "Storage",
"network": "Network",
"security": "Security",
"backup": "Backup",
"guests": "Guests",
"hardware": "Hardware"
},
"detail": {
"why": "Context",
"evidence": "Evidence",
"affected": "Affected",
"acceptedRisk": "Accepted risk"
},
"errors": {
"runFailed": "The assessment could not be started."
},
"checks": {
"backup": {
"guest_coverage": {
"title": "Backup coverage",
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
"summary": {
"noJobs": "No backup job is defined on this node",
"covered": "All {total} guests are covered by a backup job",
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
}
}
},
"system": {
"pending_reboot": {
"title": "Restart state",
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
"summary": {
"none": "No restart is pending",
"pending": "The host has a pending restart"
}
},
"enterprise_repo_without_subscription": {
"title": "Enterprise repository",
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
"summary": {
"notEnabled": "The enterprise repository is not enabled",
"subscribed": "The enterprise repository is backed by a subscription",
"unsubscribed": "The enterprise repository is enabled without an active subscription"
}
}
},
"guests": {
"privileged_containers": {
"title": "Container privileges",
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
"summary": {
"allUnprivileged": "All {total} containers are unprivileged",
"privileged": "{count} of {total} containers run privileged"
}
},
"qemu_without_agent": {
"title": "Guest agent on virtual machines",
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
"summary": {
"allHaveAgent": "All {total} virtual machines declare the guest agent",
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
}
}
},
"security": {
"host_firewall_enabled": {
"title": "Firewall state",
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
"summary": {
"bothEnabled": "The firewall is enabled at datacenter and node level",
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
}
}
},
"storage": {
"orphaned_volumes": {
"title": "Volume assignment",
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
"summary": {
"none": "No orphaned volumes were found",
"found": "{count} volume(s) belong to no existing guest"
}
}
}
},
"summaryFallback": "The check could not be evaluated",
"acceptRisk": {
"action": "Accept risk",
"revoke": "Return to active",
"title": "Accept this risk",
"reasonLabel": "Reason",
"reasonHelp": "Required. It is recorded together with the author and the date.",
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
"expiryLabel": "Review after",
"expiryHelp": "When the period ends the finding becomes active again.",
"expiryNever": "Does not expire",
"expiry90": "90 days",
"expiry180": "180 days",
"expiry365": "1 year",
"cancel": "Cancel",
"confirm": "Accept risk"
}
}
}
+342
View File
@@ -0,0 +1,342 @@
"""Check registry and evaluation engine for Audit & Report.
A check declares an identifier, an area and the severity its failure
carries, and returns the outcome of one evaluation. Checks never modify
the host: an assessment reads, it does not act.
Identifiers are ``<area>.<slug>`` and are frozen once published. Rewording
a title never changes the identifier, because the accepted-risk register
and the per-check history are keyed by it. A check whose meaning changes
materially gets a new identifier and the old one is retired rather than
reused, so a decision recorded months earlier still resolves.
Checks read from ``AuditContext``, which collects each source once per run
and hands the same result to every check that needs it. A full assessment
runs against a production hypervisor, so repeating collection per check is
not acceptable.
"""
from __future__ import annotations
import os
import re
import subprocess
import time
from pathlib import Path
from typing import Any, Callable, Optional
import audit_store
# Report areas. These group the categories `health_monitor` already emits
# so the two surfaces share one vocabulary instead of maintaining a
# parallel taxonomy.
AREA_SYSTEM = "system"
AREA_STORAGE = "storage"
AREA_NETWORK = "network"
AREA_SECURITY = "security"
AREA_BACKUP = "backup"
AREA_GUESTS = "guests"
AREA_HARDWARE = "hardware"
AREAS = (
AREA_SYSTEM, AREA_STORAGE, AREA_NETWORK, AREA_SECURITY,
AREA_BACKUP, AREA_GUESTS, AREA_HARDWARE,
)
SEVERITIES = ("OK", "INFO", "WARNING", "CRITICAL")
# Per-check wall-clock budget. A check that cannot answer within it is
# recorded as not applicable rather than stalling the whole assessment.
CHECK_TIMEOUT = 20
class Check:
"""One registered assessment.
``evaluate`` receives the context and returns a dict with ``state``
and, optionally, ``summary``, ``affected``, ``evidence`` and
``remediable_by``. Returning ``None`` marks the check as not
applicable on this host.
"""
def __init__(self, check_id: str, area: str, severity: str,
evaluate: Callable[["AuditContext"], Optional[dict]]):
if area not in AREAS:
raise ValueError(f"unknown area for {check_id}: {area}")
if severity not in SEVERITIES:
raise ValueError(f"unknown severity for {check_id}: {severity}")
if not check_id.startswith(f"{area}."):
raise ValueError(f"{check_id} must be prefixed with its area")
self.check_id = check_id
self.area = area
self.severity = severity
self.evaluate = evaluate
_REGISTRY: dict[str, Check] = {}
def register(check_id: str, area: str, severity: str):
"""Decorator registering a check under a stable identifier."""
def wrap(fn):
if check_id in _REGISTRY:
raise ValueError(f"duplicate check identifier: {check_id}")
_REGISTRY[check_id] = Check(check_id, area, severity, fn)
return fn
return wrap
def registered_checks() -> list[Check]:
return sorted(_REGISTRY.values(), key=lambda c: (c.area, c.check_id))
# ---------------------------------------------------------------------------
# Collection context
# ---------------------------------------------------------------------------
class AuditContext:
"""Lazily collects each source once and shares it across checks."""
def __init__(self):
self._cache: dict[str, Any] = {}
def _once(self, key: str, producer: Callable[[], Any]) -> Any:
if key not in self._cache:
try:
self._cache[key] = producer()
except Exception:
self._cache[key] = None
return self._cache[key]
def run(self, cmd: list[str], timeout: int = 10) -> tuple[int, str]:
"""Run a read-only command, returning exit code and output."""
key = f"cmd:{' '.join(cmd)}"
if key in self._cache:
return self._cache[key]
try:
proc = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout)
result = (proc.returncode, (proc.stdout or "") + (proc.stderr or ""))
except Exception as exc:
result = (-1, str(exc))
self._cache[key] = result
return result
@property
def lxc_configs(self) -> dict[int, str]:
"""Raw text of every local container configuration."""
def load():
out: dict[int, str] = {}
base = Path("/etc/pve/lxc")
if not base.is_dir():
return out
for path in base.glob("*.conf"):
try:
out[int(path.stem)] = path.read_text(errors="replace")
except (OSError, ValueError):
continue
return out
return self._once("lxc_configs", load) or {}
@property
def qemu_configs(self) -> dict[int, str]:
def load():
out: dict[int, str] = {}
base = Path("/etc/pve/qemu-server")
if not base.is_dir():
return out
for path in base.glob("*.conf"):
try:
out[int(path.stem)] = path.read_text(errors="replace")
except (OSError, ValueError):
continue
return out
return self._once("qemu_configs", load) or {}
@property
def apt_sources(self) -> dict[str, str]:
"""Contents of the apt source files that define PVE repositories."""
def load():
out: dict[str, str] = {}
candidates = [Path("/etc/apt/sources.list")]
d = Path("/etc/apt/sources.list.d")
if d.is_dir():
candidates.extend(sorted(d.glob("*.list")))
candidates.extend(sorted(d.glob("*.sources")))
for path in candidates:
try:
out[str(path)] = path.read_text(errors="replace")
except OSError:
continue
return out
return self._once("apt_sources", load) or {}
@property
def vzdump_jobs(self) -> str:
"""Raw backup job definitions from the cluster configuration."""
def load():
text = ""
for path in (Path("/etc/pve/jobs.cfg"), Path("/etc/vzdump.cron")):
try:
text += path.read_text(errors="replace") + "\n"
except OSError:
continue
return text
return self._once("vzdump_jobs", load) or ""
@property
def storages(self) -> list[dict]:
"""Storage definitions from ``storage.cfg``.
Each entry keeps its type, identifier and settings. ``shared``
matters to anything that reasons about ownership: on shared
storage a volume may belong to a guest running on another node,
which is invisible from here.
"""
def load():
out: list[dict] = []
try:
text = Path("/etc/pve/storage.cfg").read_text(errors="replace")
except OSError:
return out
current: Optional[dict] = None
for line in text.splitlines():
if not line.strip():
continue
header = re.match(r"^(\w+):\s*(\S+)", line)
if header:
current = {"type": header.group(1), "id": header.group(2)}
out.append(current)
continue
if current is None or not line[:1].isspace():
continue
parts = line.strip().split(None, 1)
if parts:
current[parts[0]] = parts[1] if len(parts) > 1 else ""
return out
return self._once("storages", load) or []
@property
def pve_user_cfg(self) -> str:
"""Raw access-control configuration, which also defines pools."""
def load():
try:
return Path("/etc/pve/user.cfg").read_text(errors="replace")
except OSError:
return ""
return self._once("pve_user_cfg", load) or ""
# ---------------------------------------------------------------------------
# Evaluation
# ---------------------------------------------------------------------------
def run_assessment(profile: str = "full",
only_areas: Optional[set[str]] = None) -> str:
"""Evaluate every registered check and persist the result.
A check that raises is recorded as not applicable with the error kept
as evidence. One faulty check must never abort an assessment: a
partial report that says which check failed is more useful than no
report at all.
"""
ctx = AuditContext()
exceptions = audit_store.active_exceptions()
run_id = audit_store.start_run(profile)
findings: list[dict[str, Any]] = []
error: Optional[str] = None
try:
for check in registered_checks():
if only_areas and check.area not in only_areas:
continue
started = time.monotonic()
try:
result = check.evaluate(ctx)
except Exception as exc:
result = {
"state": audit_store.STATE_NOT_APPLICABLE,
"summary_key": "evaluationFailed",
"evidence": f"{type(exc).__name__}: {exc}",
}
elapsed = time.monotonic() - started
if result is None:
result = {"state": audit_store.STATE_NOT_APPLICABLE}
state = result.get("state", audit_store.STATE_NOT_APPLICABLE)
# An accepted risk keeps its evidence and its declared
# severity; only the state changes, so the report can still
# show what was accepted and why it mattered.
if state in (audit_store.STATE_FAIL, audit_store.STATE_WARN) \
and check.check_id in exceptions:
state = audit_store.STATE_ACCEPTED
evidence = result.get("evidence")
if elapsed > CHECK_TIMEOUT:
evidence = (evidence or "") + \
f"\n[check exceeded its time budget: {elapsed:.1f}s]"
findings.append({
"check_id": check.check_id,
"area": check.area,
"severity": check.severity,
"state": state,
"summary_key": result.get("summary_key"),
"summary_params": result.get("summary_params") or {},
"affected": result.get("affected") or [],
"evidence": evidence,
"remediable_by": result.get("remediable_by"),
})
except Exception as exc:
error = f"{type(exc).__name__}: {exc}"
audit_store.record_findings(run_id, findings)
audit_store.finish_run(run_id, checks_total=len(findings), error=error)
return run_id
def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
"""Classify how findings moved between two runs.
A finding that stopped failing because someone accepted it is reported
separately from one that stopped failing because the host changed.
Both leave the active set, but only the second is a fix, and a report
that merges them would tell its reader the problem went away when the
decision was to live with it.
``unchanged`` is kept so a report can state that the rest of the
surface held steady rather than leaving it unaccounted for.
"""
failing = {audit_store.STATE_FAIL, audit_store.STATE_WARN}
base = {f["check_id"]: f for f in audit_store.get_findings(base_run)}
other = {f["check_id"]: f for f in audit_store.get_findings(other_run)}
new, resolved, accepted, unchanged = [], [], [], []
for check_id, current in other.items():
previous = base.get(check_id)
was = previous["state"] in failing if previous else False
now = current["state"] in failing
if now and not was:
new.append(current)
elif was and not now:
if current["state"] == audit_store.STATE_ACCEPTED:
accepted.append(current)
else:
resolved.append(current)
elif previous and previous["state"] == current["state"]:
unchanged.append(current)
# A check present in the base run but absent from the later one was
# retired between the two. It is reported as no longer assessed rather
# than as resolved, since nothing verified that it stopped failing.
retired = [
previous for check_id, previous in base.items()
if check_id not in other and previous["state"] in failing
]
return {
"new": new,
"resolved": resolved,
"accepted": accepted,
"unchanged": unchanged,
"retired": retired,
}
+430
View File
@@ -0,0 +1,430 @@
"""Proxmox-specific checks for Audit & Report.
Importing this module registers its checks. Everything here reads the
host and reports; nothing modifies it.
The checks are deliberately about configuration and posture rather than
transient load. A condition that resolves on its own as usage drops
belongs to the health monitor, which keeps its own catalogue and remains
the source of notifications.
"""
from __future__ import annotations
import re
from pathlib import Path
import audit_store
from audit_checks import (
AREA_BACKUP, AREA_GUESTS, AREA_SECURITY, AREA_STORAGE, AREA_SYSTEM,
register,
)
FAIL = audit_store.STATE_FAIL
WARN = audit_store.STATE_WARN
PASS = audit_store.STATE_PASS
NA = audit_store.STATE_NOT_APPLICABLE
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
def _parse_vzdump_jobs(text: str) -> list[dict]:
"""Split ``jobs.cfg`` into one entry per backup job.
A job opens with ``vzdump: <id>`` and its settings follow as indented
``key value`` lines. Values are kept verbatim; interpretation belongs
to the caller.
"""
jobs: list[dict] = []
current: dict | None = None
for line in text.splitlines():
if not line.strip():
continue
header = re.match(r"^vzdump:\s*(\S+)", line)
if header:
current = {"id": header.group(1)}
jobs.append(current)
continue
if current is None or not line[:1].isspace():
continue
parts = line.strip().split(None, 1)
if parts:
current[parts[0]] = parts[1] if len(parts) > 1 else ""
return jobs
def _pool_members(text: str) -> dict[str, set[int]]:
"""Map pool name to member guest identifiers from ``user.cfg``.
Pool entries are colon-separated: ``pool:<name>:<comment>:<vmids>:``.
"""
pools: dict[str, set[int]] = {}
for line in (text or "").splitlines():
if not line.startswith("pool:"):
continue
fields = line.split(":")
if len(fields) < 4:
continue
pools[fields[1]] = {int(x) for x in re.findall(r"\d+", fields[3])}
return pools
@register("backup.guest_coverage", AREA_BACKUP, "CRITICAL")
def _guest_coverage(ctx):
"""Guests that no enabled backup job includes.
A job selects guests by enumerating them (``vmid``), by taking every
guest (``all 1``), or by pool, and may subtract an ``exclude`` list.
A job carrying ``enabled 0`` selects nothing: it is defined but never
runs, which is precisely the situation this check exists to surface,
since a disabled job looks like coverage in the interface.
"""
guests = {}
for vmid in ctx.lxc_configs:
guests[vmid] = "lxc"
for vmid in ctx.qemu_configs:
guests[vmid] = "qemu"
if not guests:
return None
jobs = _parse_vzdump_jobs(ctx.vzdump_jobs)
if not jobs:
return {
"state": FAIL,
"summary_key": "noJobs",
"affected": [{"vmid": v, "type": t} for v, t in sorted(guests.items())],
"evidence": "no job definitions found in /etc/pve/jobs.cfg "
"or /etc/vzdump.cron",
}
pools = _pool_members(ctx.pve_user_cfg)
covered: set[int] = set()
considered: list[str] = []
skipped: list[str] = []
for job in jobs:
if job.get("enabled", "1").strip() == "0":
skipped.append(f"{job['id']} (disabled)")
continue
excluded = {int(x) for x in re.findall(r"\d+", job.get("exclude", ""))}
selected: set[int] = set()
if job.get("all", "0").strip() == "1":
selected = set(guests)
else:
selected |= {int(x) for x in re.findall(r"\d+", job.get("vmid", ""))}
for pool in re.split(r"[,\s]+", job.get("pool", "").strip()):
if pool:
selected |= pools.get(pool, set())
covered |= selected - excluded
considered.append(f"{job['id']} -> {sorted(selected - excluded) or 'nothing'}")
evidence = "enabled jobs:\n " + ("\n ".join(considered) or "(none)")
if skipped:
evidence += "\nignored jobs:\n " + "\n ".join(skipped)
uncovered = sorted(set(guests) - covered)
if not uncovered:
return {
"state": PASS,
"summary_key": "covered",
"summary_params": {"total": len(guests)},
"evidence": evidence,
}
return {
"state": FAIL,
"summary_key": "uncovered",
"summary_params": {"count": len(uncovered), "total": len(guests)},
"affected": [{"vmid": v, "type": guests[v]} for v in uncovered],
"evidence": evidence + f"\n\nuncovered: {uncovered}",
}
# ---------------------------------------------------------------------------
# System
# ---------------------------------------------------------------------------
@register("system.pending_reboot", AREA_SYSTEM, "WARNING")
def _pending_reboot(ctx):
"""Kernel or packages installed but not yet in effect."""
marker = Path("/var/run/reboot-required")
packages = ""
pkg_file = Path("/var/run/reboot-required.pkgs")
if pkg_file.exists():
try:
packages = pkg_file.read_text(errors="replace").strip()
except OSError:
packages = ""
rc, running = ctx.run(["uname", "-r"])
running = running.strip()
if not marker.exists():
return {
"state": PASS,
"summary_key": "none",
"evidence": f"running kernel: {running}",
}
return {
"state": WARN,
"summary_key": "pending",
"affected": [{"package": p} for p in packages.splitlines() if p],
"evidence": f"running kernel: {running}\n"
f"packages requesting a restart:\n{packages or '(not reported)'}",
}
@register("system.enterprise_repo_without_subscription", AREA_SYSTEM, "WARNING")
def _enterprise_repo(ctx):
"""Enterprise repository enabled on a host without a subscription.
The combination leaves ``apt update`` failing on every run, which
tends to be misread as a broken host rather than a licensing state.
"""
enabled = []
for path, text in ctx.apt_sources.items():
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("#") or not stripped:
continue
if "enterprise.proxmox.com" in stripped:
enabled.append((path, stripped))
if not enabled:
return {
"state": PASS,
"summary_key": "notEnabled",
}
rc, out = ctx.run(["pvesubscription", "get"])
status = ""
for line in (out or "").splitlines():
if line.lower().startswith("status:"):
status = line.split(":", 1)[1].strip().lower()
break
evidence = "\n".join(f"{p}: {l}" for p, l in enabled)
evidence += f"\n\npvesubscription status: {status or '(unavailable)'}"
if status in ("active", "new"):
return {
"state": PASS,
"summary_key": "subscribed",
"evidence": evidence,
}
return {
"state": WARN,
"summary_key": "unsubscribed",
"affected": [{"file": p, "line": l} for p, l in enabled],
"evidence": evidence,
}
# ---------------------------------------------------------------------------
# Guests
# ---------------------------------------------------------------------------
@register("guests.privileged_containers", AREA_GUESTS, "WARNING")
def _privileged_containers(ctx):
"""Containers running privileged.
A privileged container shares the host's user namespace, so a process
that escapes it is already root on the hypervisor. Proxmox creates
containers unprivileged by default; a container is privileged when
``unprivileged: 1`` is absent from its configuration.
"""
configs = ctx.lxc_configs
if not configs:
return None
privileged = []
for vmid, text in sorted(configs.items()):
if not re.search(r"^unprivileged:\s*1\s*$", text, re.M):
name = ""
m = re.search(r"^hostname:\s*(\S+)", text, re.M)
if m:
name = m.group(1)
privileged.append({"vmid": vmid, "name": name})
if not privileged:
return {
"state": PASS,
"summary_key": "allUnprivileged",
"summary_params": {"total": len(configs)},
}
listed = ", ".join(
f"{c['vmid']}{' (' + c['name'] + ')' if c['name'] else ''}"
for c in privileged
)
return {
"state": WARN,
"summary_key": "privileged",
"summary_params": {"count": len(privileged), "total": len(configs)},
"affected": privileged,
"evidence": f"privileged containers: {listed}",
}
@register("guests.qemu_without_agent", AREA_GUESTS, "INFO")
def _qemu_without_agent(ctx):
"""Virtual machines with no guest agent declared.
Without it the host cannot request a clean shutdown, quiesce the
filesystem for a snapshot, or report real disk usage.
"""
configs = ctx.qemu_configs
if not configs:
return None
missing = []
for vmid, text in sorted(configs.items()):
if not re.search(r"^agent:\s*(1|enabled=1)", text, re.M):
name = ""
m = re.search(r"^name:\s*(\S+)", text, re.M)
if m:
name = m.group(1)
missing.append({"vmid": vmid, "name": name})
if not missing:
return {
"state": PASS,
"summary_key": "allHaveAgent",
"summary_params": {"total": len(configs)},
}
listed = ", ".join(
f"{v['vmid']}{' (' + v['name'] + ')' if v['name'] else ''}"
for v in missing
)
return {
"state": WARN,
"summary_key": "missingAgent",
"summary_params": {"count": len(missing), "total": len(configs)},
"affected": missing,
"evidence": f"without agent: {listed}",
}
# ---------------------------------------------------------------------------
# Security
# ---------------------------------------------------------------------------
@register("security.host_firewall_enabled", AREA_SECURITY, "WARNING")
def _host_firewall(ctx):
"""Proxmox firewall enabled at datacenter and node level.
Both levels matter: the node rules are not applied while the
datacenter switch is off, so a node that looks configured can still
be filtering nothing.
"""
def enabled_in(path: Path) -> tuple[bool, str]:
try:
text = path.read_text(errors="replace")
except OSError:
return False, f"{path}: not present"
for line in text.splitlines():
if re.match(r"^\s*enable:\s*1\s*$", line):
return True, f"{path}: enable: 1"
return False, f"{path}: enable not set to 1"
dc_on, dc_note = enabled_in(Path("/etc/pve/firewall/cluster.fw"))
try:
node = Path("/etc/hostname").read_text().strip()
except OSError:
node = ""
node_path = Path(f"/etc/pve/nodes/{node}/host.fw") if node else None
node_on, node_note = (False, "node firewall file not resolved")
if node_path:
node_on, node_note = enabled_in(node_path)
evidence = f"{dc_note}\n{node_note}"
if dc_on and node_on:
return {
"state": PASS,
"summary_key": "bothEnabled",
"evidence": evidence,
}
if not dc_on:
return {
"state": WARN,
"summary_key": "datacenterOff",
"evidence": evidence,
}
return {
"state": WARN,
"summary_key": "nodeOff",
"evidence": evidence,
}
# ---------------------------------------------------------------------------
# Storage
# ---------------------------------------------------------------------------
@register("storage.orphaned_volumes", AREA_STORAGE, "WARNING")
def _orphaned_volumes(ctx):
"""Disk images that no guest configuration references.
A volume survives when a guest is removed without its disks, or when
a restore leaves the previous copy behind. Nothing reports it and it
keeps occupying the pool.
Only storage that is not shared is examined. On shared storage a
volume may belong to a guest running on another node, which this node
cannot see, so flagging it would be wrong rather than merely noisy.
"""
known = set(ctx.lxc_configs) | set(ctx.qemu_configs)
if not known:
return None
candidates = [
s for s in ctx.storages
if str(s.get("shared", "0")).strip() != "1"
and any(c in (s.get("content") or "") for c in ("images", "rootdir"))
]
if not candidates:
return None
orphans: list[dict] = []
inspected: list[str] = []
for storage in candidates:
sid = storage["id"]
rc, out = ctx.run(["pvesm", "list", sid], timeout=15)
if rc != 0:
inspected.append(f"{sid}: not readable")
continue
count = 0
for line in (out or "").splitlines()[1:]:
fields = line.split()
if len(fields) < 5:
continue
volid, vmid_raw = fields[0], fields[-1]
if not vmid_raw.isdigit():
continue
count += 1
vmid = int(vmid_raw)
if vmid not in known:
orphans.append({"volume": volid, "vmid": vmid})
inspected.append(f"{sid}: {count} volume(s)")
shared_skipped = [
s["id"] for s in ctx.storages
if str(s.get("shared", "0")).strip() == "1"
]
evidence = "inspected:\n " + "\n ".join(inspected)
if shared_skipped:
evidence += ("\nskipped as shared (ownership not resolvable from this "
"node):\n " + ", ".join(shared_skipped))
if not orphans:
return {
"state": PASS,
"summary_key": "none",
"evidence": evidence,
}
return {
"state": WARN,
"summary_key": "found",
"summary_params": {"count": len(orphans)},
"affected": orphans,
"evidence": evidence + "\n\norphans:\n " + "\n ".join(
f"{o['volume']} (no config for {o['vmid']})" for o in orphans),
}
+426
View File
@@ -0,0 +1,426 @@
"""Persistence layer for Audit & Report.
Holds assessment runs, their findings, the accepted-risk register and the
designated baseline.
The store lives in its own database rather than alongside health and
notification state. An assessment writes every finding of a run in one
burst and its retention pass deletes whole runs; sharing a file with the
notification dispatcher which opens ``BEGIN IMMEDIATE`` transactions on
every delivered event would make those two paths contend for the same
write lock.
Findings persist i18n keys, never rendered text. A report exported today
may be read in a different language than the one active when the
assessment ran, and the printed document renders from the key at
presentation time. Evidence is the exception: it is raw command output
and is stored verbatim.
"""
from __future__ import annotations
import json
import sqlite3
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Optional
DB_PATH = Path("/usr/local/share/proxmenux/audit.db")
# Result of a check within one run. Severity is what the check declares
# for a failure; state is what actually happened this time.
STATE_FAIL = "fail"
STATE_WARN = "warn"
STATE_PASS = "pass"
STATE_NOT_APPLICABLE = "not_applicable"
STATE_ACCEPTED = "accepted"
RUN_RUNNING = "running"
RUN_COMPLETE = "complete"
RUN_FAILED = "failed"
_schema_lock = threading.Lock()
_schema_ready = False
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH), timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db() -> None:
"""Create the schema. Safe to call repeatedly."""
global _schema_ready
with _schema_lock:
if _schema_ready:
return
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = _connect()
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS audit_runs (
run_id TEXT PRIMARY KEY,
profile TEXT NOT NULL,
started_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
error TEXT,
is_baseline INTEGER NOT NULL DEFAULT 0,
checks_total INTEGER NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT 1
);
-- summary_key names a translation entry and summary_params
-- carries its placeholders. Storing a rendered sentence
-- instead would freeze a finding in whichever language was
-- active when the assessment ran, and a report exported
-- today may well be read in another one.
CREATE TABLE IF NOT EXISTS audit_findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
check_id TEXT NOT NULL,
area TEXT NOT NULL,
severity TEXT NOT NULL,
state TEXT NOT NULL,
summary_key TEXT,
summary_params TEXT,
affected TEXT,
evidence TEXT,
remediable_by TEXT,
FOREIGN KEY (run_id) REFERENCES audit_runs(run_id)
ON DELETE CASCADE
);
-- Accepted risks outlive the run that surfaced them, so they
-- are keyed by check rather than by finding. expires_at NULL
-- means the acceptance does not lapse on its own.
CREATE TABLE IF NOT EXISTS audit_exceptions (
check_id TEXT PRIMARY KEY,
reason TEXT NOT NULL,
accepted_by TEXT NOT NULL,
accepted_at INTEGER NOT NULL,
expires_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_audit_findings_run
ON audit_findings(run_id);
CREATE INDEX IF NOT EXISTS idx_audit_findings_check
ON audit_findings(check_id);
CREATE INDEX IF NOT EXISTS idx_audit_runs_started
ON audit_runs(started_at);
""")
conn.commit()
_schema_ready = True
finally:
conn.close()
# ---------------------------------------------------------------------------
# Runs
# ---------------------------------------------------------------------------
def start_run(profile: str) -> str:
"""Open a run and return its identifier."""
init_db()
run_id = uuid.uuid4().hex[:16]
conn = _connect()
try:
conn.execute(
"INSERT INTO audit_runs (run_id, profile, started_at, status) "
"VALUES (?, ?, ?, ?)",
(run_id, profile, int(time.time()), RUN_RUNNING),
)
conn.commit()
finally:
conn.close()
return run_id
def finish_run(run_id: str, *, checks_total: int,
error: Optional[str] = None) -> None:
"""Close a run, marking it failed when an error is supplied."""
init_db()
conn = _connect()
try:
conn.execute(
"UPDATE audit_runs SET finished_at = ?, status = ?, error = ?, "
"checks_total = ? WHERE run_id = ?",
(int(time.time()), RUN_FAILED if error else RUN_COMPLETE,
error, checks_total, run_id),
)
conn.commit()
finally:
conn.close()
def get_run(run_id: str) -> Optional[dict[str, Any]]:
init_db()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM audit_runs WHERE run_id = ?", (run_id,)
).fetchone()
return dict(row) if row else None
finally:
conn.close()
def list_runs(limit: int = 20) -> list[dict[str, Any]]:
init_db()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM audit_runs ORDER BY started_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def latest_run(status: str = RUN_COMPLETE) -> Optional[dict[str, Any]]:
init_db()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM audit_runs WHERE status = ? "
"ORDER BY started_at DESC LIMIT 1",
(status,),
).fetchone()
return dict(row) if row else None
finally:
conn.close()
# ---------------------------------------------------------------------------
# Findings
# ---------------------------------------------------------------------------
def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int:
"""Write a run's findings in a single transaction.
``affected`` is stored as JSON so a check that covers several objects
keeps the per-object detail without emitting one finding per object.
"""
init_db()
if not findings:
return 0
rows = [
(
run_id,
f["check_id"],
f["area"],
f["severity"],
f["state"],
f.get("summary_key"),
json.dumps(f.get("summary_params") or {}, ensure_ascii=False),
json.dumps(f.get("affected") or [], ensure_ascii=False),
f.get("evidence"),
f.get("remediable_by"),
)
for f in findings
]
conn = _connect()
try:
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"INSERT INTO audit_findings (run_id, check_id, area, severity, "
"state, summary_key, summary_params, affected, evidence, "
"remediable_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rows,
)
conn.commit()
finally:
conn.close()
return len(rows)
def get_findings(run_id: str) -> list[dict[str, Any]]:
init_db()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM audit_findings WHERE run_id = ? ORDER BY id",
(run_id,),
).fetchall()
out = []
for r in rows:
item = dict(r)
try:
item["affected"] = json.loads(item.get("affected") or "[]")
except (TypeError, ValueError):
item["affected"] = []
try:
item["summary_params"] = json.loads(
item.get("summary_params") or "{}")
except (TypeError, ValueError):
item["summary_params"] = {}
out.append(item)
return out
finally:
conn.close()
def check_history(check_id: str, limit: int = 30) -> list[dict[str, Any]]:
"""Return how one check resolved across recent runs."""
init_db()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT f.state, f.severity, r.run_id, r.started_at "
"FROM audit_findings f JOIN audit_runs r ON r.run_id = f.run_id "
"WHERE f.check_id = ? ORDER BY r.started_at DESC LIMIT ?",
(check_id, limit),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
# ---------------------------------------------------------------------------
# Accepted risks
# ---------------------------------------------------------------------------
def accept_risk(check_id: str, reason: str, accepted_by: str,
expires_at: Optional[int] = None) -> None:
"""Record a deliberate decision to leave a finding unresolved.
A reason is mandatory: an acceptance without one is indistinguishable
from having silenced the check, which is what this register exists to
prevent.
"""
if not (reason or "").strip():
raise ValueError("an accepted risk requires a reason")
init_db()
conn = _connect()
try:
conn.execute(
"INSERT OR REPLACE INTO audit_exceptions "
"(check_id, reason, accepted_by, accepted_at, expires_at) "
"VALUES (?, ?, ?, ?, ?)",
(check_id, reason.strip(), accepted_by, int(time.time()),
expires_at),
)
conn.commit()
finally:
conn.close()
def revoke_risk(check_id: str) -> bool:
init_db()
conn = _connect()
try:
cur = conn.execute(
"DELETE FROM audit_exceptions WHERE check_id = ?", (check_id,)
)
conn.commit()
return cur.rowcount > 0
finally:
conn.close()
def active_exceptions() -> dict[str, dict[str, Any]]:
"""Return accepted risks that have not lapsed, keyed by check.
Lapsed entries are left on disk so the decision remains auditable;
they simply stop suppressing the finding.
"""
init_db()
now = int(time.time())
conn = _connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM audit_exceptions "
"WHERE expires_at IS NULL OR expires_at > ?",
(now,),
).fetchall()
return {r["check_id"]: dict(r) for r in rows}
finally:
conn.close()
def all_exceptions() -> list[dict[str, Any]]:
init_db()
now = int(time.time())
conn = _connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM audit_exceptions ORDER BY accepted_at DESC"
).fetchall()
out = []
for r in rows:
item = dict(r)
item["lapsed"] = bool(
item["expires_at"] is not None and item["expires_at"] <= now
)
out.append(item)
return out
finally:
conn.close()
# ---------------------------------------------------------------------------
# Baseline and retention
# ---------------------------------------------------------------------------
def set_baseline(run_id: str) -> None:
"""Designate a run as the reference to compare later runs against."""
init_db()
conn = _connect()
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("UPDATE audit_runs SET is_baseline = 0")
conn.execute(
"UPDATE audit_runs SET is_baseline = 1 WHERE run_id = ?", (run_id,)
)
conn.commit()
finally:
conn.close()
def get_baseline() -> Optional[dict[str, Any]]:
init_db()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM audit_runs WHERE is_baseline = 1 LIMIT 1"
).fetchone()
return dict(row) if row else None
finally:
conn.close()
def prune_runs(keep: int = 30) -> int:
"""Drop the oldest runs beyond ``keep``.
The baseline is never pruned: it is the reference every comparison is
measured against and losing it silently would break that comparison
long after the run that produced it was forgotten.
"""
init_db()
conn = _connect()
try:
conn.execute("BEGIN IMMEDIATE")
cur = conn.execute(
"DELETE FROM audit_runs WHERE is_baseline = 0 AND run_id NOT IN ("
" SELECT run_id FROM audit_runs "
" WHERE is_baseline = 0 ORDER BY started_at DESC LIMIT ?"
")",
(keep,),
)
conn.commit()
return cur.rowcount
finally:
conn.close()
+4
View File
@@ -165,6 +165,10 @@ cp "$SCRIPT_DIR/startup_grace.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
cp "$SCRIPT_DIR/flask_notification_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_notification_routes.py not found"
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found"
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
cp "$SCRIPT_DIR/audit_checks_pve.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks_pve.py not found"
cp "$SCRIPT_DIR/oci/description_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ description_templates.py not found"
# Copy AI providers module for notification enhancement
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ProxMenux Audit Routes
Flask blueprint for the Audit & Report assessment engine.
An assessment reads the host and records findings; it never modifies
anything. The run endpoint is therefore the only POST that does real
work, and it is deliberately serialised: two concurrent assessments would
compete for the same collectors without producing a better answer.
"""
import threading
import time
from flask import Blueprint, jsonify, request
from jwt_middleware import require_auth
audit_bp = Blueprint('audit', __name__)
try:
import audit_store
import audit_checks
import audit_checks_pve # noqa: F401 — importing registers the checks
except ImportError:
audit_store = None
audit_checks = None
# One assessment at a time. The flag is also what the interface polls to
# know a run is still in progress.
_run_lock = threading.Lock()
_running: dict = {'active': False, 'run_id': None, 'started_at': 0}
def _unavailable():
return jsonify({
"success": False,
"message": "Audit engine not available",
}), 500
@audit_bp.route('/api/audit/checks', methods=['GET'])
@require_auth
def list_checks():
"""Catalogue of registered checks, independent of any run."""
if not audit_checks:
return _unavailable()
try:
return jsonify({
"success": True,
"areas": list(audit_checks.AREAS),
"checks": [
{
"check_id": c.check_id,
"area": c.area,
"severity": c.severity,
}
for c in audit_checks.registered_checks()
],
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/status', methods=['GET'])
@require_auth
def status():
"""Latest run, whether an assessment is in progress, and the baseline."""
if not audit_store:
return _unavailable()
try:
latest = audit_store.latest_run()
summary = {}
if latest:
for f in audit_store.get_findings(latest['run_id']):
summary[f['state']] = summary.get(f['state'], 0) + 1
return jsonify({
"success": True,
"running": _running['active'],
"latest": latest,
"summary": summary,
"baseline": audit_store.get_baseline(),
"exceptions": len(audit_store.active_exceptions()),
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/run', methods=['POST'])
@require_auth
def run():
"""Start an assessment in the background.
The response returns immediately with the run identifier; the
interface polls ``/api/audit/status``. A full assessment is short but
runs against a production host, so it must not hold an HTTP worker.
"""
if not audit_checks:
return _unavailable()
data = request.get_json(silent=True) or {}
profile = str(data.get('profile') or 'full')
areas = data.get('areas')
only = set(areas) if isinstance(areas, list) and areas else None
with _run_lock:
if _running['active']:
return jsonify({
"success": False,
"message": "An assessment is already running",
"run_id": _running['run_id'],
}), 409
_running.update({'active': True, 'run_id': None,
'started_at': time.time()})
def worker():
try:
run_id = audit_checks.run_assessment(profile, only_areas=only)
_running['run_id'] = run_id
audit_store.prune_runs()
except Exception as e:
print(f"[audit] assessment failed: {e}")
finally:
_running['active'] = False
threading.Thread(target=worker, daemon=True, name='audit-run').start()
return jsonify({"success": True, "started": True})
@audit_bp.route('/api/audit/runs', methods=['GET'])
@require_auth
def runs():
if not audit_store:
return _unavailable()
try:
limit = min(int(request.args.get('limit', 20)), 100)
return jsonify({"success": True, "runs": audit_store.list_runs(limit)})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/runs/<run_id>', methods=['GET'])
@require_auth
def run_detail(run_id):
"""Findings of one run, with the accepted-risk record attached.
Accepted findings are returned like any other so the interface can
show them muted rather than dropping them: hiding an accepted risk
turns the register into a way of forgetting decisions.
"""
if not audit_store:
return _unavailable()
try:
run = audit_store.get_run(run_id)
if not run:
return jsonify({"success": False, "message": "Run not found"}), 404
exceptions = audit_store.active_exceptions()
findings = audit_store.get_findings(run_id)
for f in findings:
f['exception'] = exceptions.get(f['check_id'])
return jsonify({"success": True, "run": run, "findings": findings})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/compare', methods=['GET'])
@require_auth
def compare():
"""Difference between two runs, defaulting the base to the baseline."""
if not audit_store:
return _unavailable()
try:
other = request.args.get('to')
base = request.args.get('from')
if not base:
baseline = audit_store.get_baseline()
base = baseline['run_id'] if baseline else None
if not other:
latest = audit_store.latest_run()
other = latest['run_id'] if latest else None
if not base or not other:
return jsonify({
"success": False,
"message": "Two runs are required to compare",
}), 400
return jsonify({
"success": True,
"from": base,
"to": other,
**audit_checks.compare_runs(base, other),
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/baseline', methods=['POST'])
@require_auth
def set_baseline():
if not audit_store:
return _unavailable()
try:
data = request.get_json(silent=True) or {}
run_id = data.get('run_id')
if not run_id or not audit_store.get_run(run_id):
return jsonify({"success": False, "message": "Run not found"}), 404
audit_store.set_baseline(run_id)
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/exceptions', methods=['GET'])
@require_auth
def list_exceptions():
if not audit_store:
return _unavailable()
try:
return jsonify({
"success": True,
"exceptions": audit_store.all_exceptions(),
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/exceptions', methods=['POST'])
@require_auth
def accept_exception():
"""Record a finding as a deliberate decision.
The reason is mandatory. An acceptance without one cannot be
distinguished later from having silenced the check, which is the
outcome this register exists to prevent.
"""
if not audit_store:
return _unavailable()
try:
data = request.get_json(silent=True) or {}
check_id = (data.get('check_id') or '').strip()
reason = (data.get('reason') or '').strip()
if not check_id:
return jsonify({"success": False,
"message": "check_id is required"}), 400
if not reason:
return jsonify({"success": False,
"message": "A reason is required"}), 400
expires_at = None
days = data.get('expires_in_days')
if days:
try:
expires_at = int(time.time()) + int(days) * 86400
except (TypeError, ValueError):
return jsonify({"success": False,
"message": "Invalid expiry"}), 400
audit_store.accept_risk(
check_id, reason,
accepted_by=str(data.get('accepted_by') or 'admin'),
expires_at=expires_at,
)
return jsonify({"success": True})
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/exceptions/<path:check_id>', methods=['DELETE'])
@require_auth
def revoke_exception(check_id):
if not audit_store:
return _unavailable()
try:
removed = audit_store.revoke_risk(check_id)
if not removed:
return jsonify({"success": False,
"message": "Exception not found"}), 404
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
+198 -357
View File
@@ -92,6 +92,7 @@ from flask_proxmenux_routes import proxmenux_bp # noqa: E402
from flask_security_routes import security_bp # noqa: E402
from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
from flask_audit_routes import audit_bp # noqa: E402
from notification_manager import notification_manager # noqa: E402
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
@@ -227,6 +228,7 @@ app.register_blueprint(proxmenux_bp)
app.register_blueprint(security_bp)
app.register_blueprint(notification_bp)
app.register_blueprint(oci_bp)
app.register_blueprint(audit_bp)
# Initialize terminal / WebSocket routes
init_terminal_routes(app)
@@ -1697,7 +1699,7 @@ _VM_DISK_REFRESH_WORKERS = 6 # parallelism cap for the fsinfo pass
# state must be probed rather than inferred from an API response.
_vm_details_cache: dict = {} # vmid -> (ts, payload)
_vm_backups_cache: dict = {} # vmid -> (ts, payload)
_vm_apps_cache: dict = {} # vmid -> (ts, payload)
# Registered apps use lxc_apps' shared, file-versioned snapshot cache.
_vm_app_suggestions_cache: dict = {} # vmid -> (ts, payload)
_vm_schedule_cache: dict = {} # vmid -> (ts, payload)
_vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
@@ -1735,7 +1737,6 @@ _lxc_ip_cache: dict = {}
_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_APP_SUGGESTIONS_TTL = _VM_CACHE_INDEFINITE
_VM_SCHEDULE_TTL = _VM_CACHE_INDEFINITE
_VM_MOUNTS_TTL = _VM_CACHE_INDEFINITE
@@ -1817,7 +1818,7 @@ def _vm_cache_invalidate(vmid: int, *caches) -> None:
affect any of them (e.g. control start/stop flips status, which
lives in the details payload)."""
targets = caches or (
_vm_details_cache, _vm_backups_cache, _vm_apps_cache,
_vm_details_cache, _vm_backups_cache,
_vm_app_suggestions_cache, _vm_schedule_cache, _vm_mounts_cache,
)
with _vm_modal_cache_lock:
@@ -1906,7 +1907,6 @@ def _refresh_started_guest(vmid: int, vm_type: str) -> None:
sidecar = lxc_apps.check_all(vmid, force=True)
sidecar = sidecar or {'vmid': vmid, 'apps': []}
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
docker_registered = any(
isinstance(item, dict) and item.get('helper_slug') == 'docker'
@@ -11891,51 +11891,69 @@ def api_vm_metrics(vmid):
return jsonify({'error': str(e)}), 500
# Per-process cache for the RRD payload of /api/node/metrics. Two unrelated
# dashboard components (`network-traffic-chart` for the network panel and
# `node-metrics-charts` for the CPU/memory panel) mount in parallel on the
# Overview page and each fires this endpoint independently with the same
# `?timeframe=` argument. The underlying `pvesh get rrddata` call takes
# ~1 second; without a cache, the second fetch blocks behind the first
# (especially under gevent), occasionally surfacing as a transient 502
# while gevent is single-threaded for blocking calls. RRD data is updated
# on a per-minute cadence by PVE, so a 10-second cache is safe and the
# UI experience is materially better.
# Shared RRD snapshots and single-flight locks, one per supported timeframe.
_NODE_METRICS_TTL = 120.0
_NODE_METRICS_TIMEOUT = 30.0
_NODE_METRICS_RETRY_DELAY = 30.0
_NODE_METRICS_CACHE = {}
# TTL is 120 s because the prewarmer only refreshes the `hour`
# timeframe (the Overview's default) every 90 s. The other four
# timeframes get their first fetch lazily when the user picks them —
# they then live in cache for 120 s, which covers back-and-forth
# switching without paying pvesh cost. Pre-warming every timeframe
# on a fast cadence (as the first version did) burned ~30 % of a
# core continuously scanning data nobody was looking at.
_NODE_METRICS_TTL = 120.0 # seconds
_NODE_METRICS_FAILURES = {}
_NODE_METRICS_LOCKS = {
timeframe: threading.Lock()
for timeframe in ('hour', 'day', 'week', 'month', 'year')
}
def _node_metrics_cache_get(timeframe):
entry = _NODE_METRICS_CACHE.get(timeframe)
if not entry:
return None
if time.monotonic() - entry['ts'] > _NODE_METRICS_TTL:
return None
return entry['payload']
class _NodeMetricsError(Exception):
def __init__(self, payload):
super().__init__(payload['error'])
self.payload = payload
def _node_metrics_cache_set(timeframe, payload):
_NODE_METRICS_CACHE[timeframe] = {'payload': payload, 'ts': time.monotonic()}
def _node_metrics_fallback(timeframe, error):
cached = _NODE_METRICS_CACHE.get(timeframe)
if cached is not None:
return {**cached['payload'], 'cache_status': 'stale', 'refresh_error': error}
raise _NodeMetricsError(error)
def _compute_node_metrics_payload(timeframe: str) -> dict | None:
"""Do the actual pvesh-backed RRD fetch + massaging that
`api_node_metrics` used to do inline. Returns the payload dict on
success (and populates the cache), None on any failure.
Extracted so the background prewarmer can call it without going
through HTTP + `@require_auth` same code path as the handler,
zero duplication of the massaging logic."""
def _get_node_metrics_payload(timeframe, max_age=_NODE_METRICS_TTL):
lock = _NODE_METRICS_LOCKS[timeframe]
if not lock.acquire(timeout=_NODE_METRICS_TIMEOUT + 5):
return _node_metrics_fallback(timeframe, {
'error': 'A metrics query is still in progress', 'code': 'metrics_busy',
})
try:
now = time.monotonic()
cached = _NODE_METRICS_CACHE.get(timeframe)
failure = _NODE_METRICS_FAILURES.get(timeframe)
if failure is not None and now - failure['ts'] < _NODE_METRICS_RETRY_DELAY:
return _node_metrics_fallback(timeframe, failure['error'])
if cached is not None and now - cached['ts'] < max_age:
return cached['payload']
try:
payload = _compute_node_metrics_payload(timeframe)
except Exception as exc:
error = exc.payload if isinstance(exc, _NodeMetricsError) else {
'error': 'Unable to load Proxmox metrics', 'code': 'metrics_unavailable',
}
_NODE_METRICS_FAILURES[timeframe] = {'error': error, 'ts': time.monotonic()}
print(f"[ProxMenux] node metrics ({timeframe}): {exc}", file=sys.stderr, flush=True)
return _node_metrics_fallback(timeframe, error)
payload = {**payload, 'last_checked': int(time.time()), 'cache_status': 'fresh'}
_NODE_METRICS_CACHE[timeframe] = {'payload': payload, 'ts': time.monotonic()}
_NODE_METRICS_FAILURES.pop(timeframe, None)
return payload
finally:
lock.release()
def _compute_node_metrics_payload(timeframe: str) -> dict:
"""Fetch and shape one node RRD snapshot within a bounded query budget."""
valid_timeframes = ('hour', 'day', 'week', 'month', 'year')
if timeframe not in valid_timeframes:
return None
raise ValueError('Invalid timeframe')
deadline = time.monotonic() + _NODE_METRICS_TIMEOUT
local_node = get_proxmox_node_name()
zfs_arc_size = 0
@@ -11954,16 +11972,70 @@ def _compute_node_metrics_payload(timeframe: str) -> dict | None:
rrd_result = subprocess.run(
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
'--timeframe', timeframe, '--output-format', 'json'],
capture_output=True, text=True, timeout=10,
capture_output=True, text=True, timeout=_NODE_METRICS_TIMEOUT,
)
except (subprocess.SubprocessError, OSError):
return None
except subprocess.TimeoutExpired:
raise _NodeMetricsError({
'error': 'Proxmox metrics query timed out',
'code': 'metrics_timeout',
'details': 'The metrics query exceeded the 30-second limit. It can be retried without restarting any services.',
})
except (subprocess.SubprocessError, OSError) as exc:
raise _NodeMetricsError({'error': 'Proxmox metrics command failed', 'raw': str(exc)[:500]})
if rrd_result.returncode != 0:
return None
stderr_str = (rrd_result.stderr or '') + (rrd_result.stdout or '')
stderr_lower = stderr_str.lower()
if 'mmaping file' in stderr_lower and 'invalid argument' in stderr_lower:
raise _NodeMetricsError({
'error': 'Proxmox RRD database is corrupt',
'details': (
'The host metrics file Proxmox keeps under '
'/var/lib/rrdcached/db/pve-node-9.0/ failed to '
'memory-map (Invalid argument). This is a Proxmox-side '
'data-store issue, not a Monitor bug.'
),
'suggestion': (
'Stop pvestatd + pve-cluster + rrdcached, move the '
'broken RRD aside, restart the services. Proxmox will '
'rebuild the RRD from scratch (history is lost).'
),
'raw': stderr_str.strip()[:500],
})
if 'no such file' in stderr_lower or 'no such node' in stderr_lower or 'does not exist' in stderr_lower:
raise _NodeMetricsError({
'error': 'Proxmox node name mismatch',
'details': (
f"pvesh could not find node '{local_node}'. The "
'usual cause is that the host was renamed after '
'Proxmox was installed, so /etc/pve/nodes/ still '
'carries the old name. This is a Proxmox-side '
'config issue, not a Monitor bug.'
),
'suggestion': 'Compare `hostname` with `ls /etc/pve/nodes/` — they must match.',
'raw': stderr_str.strip()[:500],
})
if 'rrd' in stderr_lower or 'empty' in stderr_lower:
raise _NodeMetricsError({
'error': 'Proxmox RRD data not available',
'details': 'The RRD database appears empty. Proxmox may not have collected metrics yet (fresh install) or rrdcached was down at boot.',
'suggestion': 'systemctl restart rrdcached pvestatd ; wait ~5 min and reload this page.',
'raw': stderr_str.strip()[:500],
})
raise _NodeMetricsError({
'error': 'Proxmox metrics command failed',
'details': 'pvesh exited non-zero. Check Proxmox host status.',
'raw': stderr_str.strip()[:500],
})
try:
rrd_data = json.loads(rrd_result.stdout)
if not isinstance(rrd_data, list) or any(not isinstance(item, dict) for item in rrd_data):
raise ValueError('Expected an array of RRD points')
except (json.JSONDecodeError, ValueError):
return None
raise _NodeMetricsError({
'error': 'Proxmox returned invalid metrics data',
'code': 'metrics_invalid_data',
})
for item in rrd_data:
if 'arcsize' in item:
@@ -11988,34 +12060,35 @@ def _compute_node_metrics_payload(timeframe: str) -> dict | None:
}
def _pvesh_rrd(cf):
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
try:
extra = subprocess.run(
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
'--timeframe', timeframe, '--cf', cf,
'--output-format', 'json'],
capture_output=True, text=True, timeout=10,
capture_output=True, text=True, timeout=remaining,
)
if extra.returncode == 0 and extra.stdout:
return json.loads(extra.stdout)
points = json.loads(extra.stdout)
if isinstance(points, list) and all(isinstance(item, dict) for item in points):
return points
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
pass
return None
# PVE supports AVERAGE and MAX, not MIN. Share MAX across both charts.
cf_max = _pvesh_rrd('MAX') if timeframe in ('week', 'month') else None
def _build_stats(field_key, scale=1.0):
native = _stats_native(field_key, scale)
if native is None:
return None
if timeframe in ('week', 'month'):
cf_max = _pvesh_rrd('MAX')
if cf_max:
vals = _values_from(cf_max, field_key, scale)
if vals:
native['max'] = max(vals)
cf_min = _pvesh_rrd('MIN')
if cf_min:
vals = _values_from(cf_min, field_key, scale)
if vals:
native['min'] = min(vals)
if cf_max:
vals = _values_from(cf_max, field_key, scale)
if vals:
native['max'] = max(vals)
return native
period_stats = {
@@ -12053,28 +12126,17 @@ def _compute_node_metrics_payload(timeframe: str) -> dict | None:
'data': rrd_data,
'period_stats': period_stats,
}
_node_metrics_cache_set(timeframe, payload)
return payload
def _node_metrics_prewarmer_loop():
"""Keep `_NODE_METRICS_CACHE['hour']` hot so the Overview page's
default view (CPU + Memory charts, 1-hour range) never waits on
`pvesh get rrddata`. Only `hour` is prewarmed the other
timeframes (day/week/month/year) are lazy-cached on first click
and stick around for the 120 s TTL. Prewarming every timeframe
burned ~30 % of a core continuously against pvesh for data
nobody was looking at, and week/month each cost 3 pvesh calls
(base + MAX + MIN)."""
time.sleep(3) # let the app finish importing before the first pass
"""Prewarm the Overview's default range using the same single-flight cache."""
time.sleep(3)
while True:
try:
_compute_node_metrics_payload('hour')
except Exception as e:
print(f"[ProxMenux] node-metrics prewarmer error: {e}",
file=sys.stderr, flush=True)
# Refresh well before the 120 s TTL expires so the user never
# hits a cold cache during a natural page open.
_get_node_metrics_payload('day', max_age=90.0)
except _NodeMetricsError:
pass # The shared fetch path already records the failure.
time.sleep(90)
@@ -12105,9 +12167,9 @@ def _vm_modal_prewarmer_pass():
f'/api/vms/{vmid}/backups', 'backups'),
]
if vm_type == 'lxc':
import lxc_apps
lxc_apps.load_sidecar(vmid)
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,
@@ -12176,249 +12238,17 @@ def _vm_modal_prewarmer_loop():
@app.route('/api/node/metrics', methods=['GET'])
@require_auth
def api_node_metrics():
"""Get historical metrics (RRD data) for the node.
Per-timeframe cached for ~10 s so the two dashboard panels that mount
together don't hit `pvesh` twice; see `_NODE_METRICS_CACHE` comment.
"""
"""Share one cached RRD snapshot across the dashboard's charts."""
timeframe = request.args.get('timeframe', 'week')
if timeframe not in _NODE_METRICS_LOCKS:
return jsonify({'error': 'Invalid timeframe. Must be one of: hour, day, week, month, year'}), 400
try:
timeframe = request.args.get('timeframe', 'week') # hour, day, week, month, year
# Validate timeframe
valid_timeframes = ['hour', 'day', 'week', 'month', 'year']
if timeframe not in valid_timeframes:
return jsonify({'error': f'Invalid timeframe. Must be one of: {", ".join(valid_timeframes)}'}), 400
# Serve from cache when fresh — completely skips the pvesh call.
cached = _node_metrics_cache_get(timeframe)
if cached is not None:
return jsonify(cached)
# Get local node name
# local_node = socket.gethostname()
local_node = get_proxmox_node_name()
# print(f"[v0] Local node: {local_node}")
pass
zfs_arc_size = 0
try:
with open('/proc/spl/kstat/zfs/arcstats', 'r') as f:
for line in f:
if line.startswith('size'):
parts = line.split()
if len(parts) >= 3:
zfs_arc_size = int(parts[2])
break
except (FileNotFoundError, PermissionError, ValueError):
# ZFS not available or no access
pass
# Get RRD data for the node
rrd_result = subprocess.run(['pvesh', 'get', f'/nodes/{local_node}/rrddata',
'--timeframe', timeframe, '--output-format', 'json'],
capture_output=True, text=True, timeout=10)
# Detect well-known Proxmox-side failures BEFORE trying to parse
# the JSON. These are PVE host problems (rrdcached down, RRD file
# corrupt, node-name mismatch). None of them are caused by the
# Monitor itself — surface a specific message so the operator
# doesn't blame ProxMenux for a Proxmox-host data-store issue.
if rrd_result.returncode != 0:
stderr_str = (rrd_result.stderr or '') + (rrd_result.stdout or '')
stderr_lower = stderr_str.lower()
if 'mmaping file' in stderr_lower and 'invalid argument' in stderr_lower:
# Corrupt RRD file on disk. Operator must recreate it.
return jsonify({
'error': 'Proxmox RRD database is corrupt',
'details': (
'The host metrics file Proxmox keeps under '
'/var/lib/rrdcached/db/pve-node-9.0/ failed to '
'memory-map (Invalid argument). This is a Proxmox-side '
'data-store issue, not a Monitor bug.'
),
'suggestion': (
'Stop pvestatd + pve-cluster + rrdcached, move the '
'broken RRD aside, restart the services. Proxmox will '
'rebuild the RRD from scratch (history is lost).'
),
'raw': stderr_str.strip()[:500],
}), 503
if 'no such file' in stderr_lower or 'no such node' in stderr_lower or 'does not exist' in stderr_lower:
return jsonify({
'error': 'Proxmox node name mismatch',
'details': (
f"pvesh could not find node '{local_node}'. The "
'usual cause is that the host was renamed after '
'Proxmox was installed, so /etc/pve/nodes/ still '
'carries the old name. This is a Proxmox-side '
'config issue, not a Monitor bug.'
),
'suggestion': 'Compare `hostname` with `ls /etc/pve/nodes/` — they must match.',
'raw': stderr_str.strip()[:500],
}), 503
if 'rrd' in stderr_lower or 'empty' in stderr_lower:
return jsonify({
'error': 'Proxmox RRD data not available',
'details': 'The RRD database appears empty. Proxmox may not have collected metrics yet (fresh install) or rrdcached was down at boot.',
'suggestion': 'systemctl restart rrdcached pvestatd ; wait ~5 min and reload this page.',
'raw': stderr_str.strip()[:500],
}), 503
return jsonify({
'error': 'Proxmox metrics command failed',
'details': 'pvesh exited non-zero. Check Proxmox host status.',
'raw': stderr_str.strip()[:500],
}), 503
if rrd_result.returncode == 0:
rrd_data = json.loads(rrd_result.stdout)
# PVE 9.x exposes the actual ARC history as `arcsize` in RRD;
# the previous code ignored it and stamped every point with
# the live ARC size, producing a flat band at the current
# value (issue: ZFS ARC line painted full-bar). Use the real
# series when present so the chart matches Proxmox's own
# Summary view. On older PVE that doesn't expose `arcsize`,
# fall back to the live value as a constant placeholder.
for item in rrd_data:
if 'arcsize' in item:
item['zfsarc'] = item['arcsize']
elif zfs_arc_size > 0 and ('zfsarc' not in item or item.get('zfsarc', 0) == 0):
item['zfsarc'] = zfs_arc_size
# Period stats — computed BEFORE downsampling so the
# AVG/MAX/MIN header in the chart reflects real per-minute
# extremes instead of averages.
#
# Three sources depending on the timeframe:
#
# - hour/day → PVE returns 1-min raw points. AVG/MAX/MIN
# of the in-memory list IS the truth.
#
# - week/month → PVE already downsamples to 30-min /
# ~1-hour points using consolidation function AVG, so
# the in-memory points are already averages. Taking
# max() of them gives "max of averages", NOT the real
# peak. We issue two extra pvesh calls per request
# (`--cf MAX` and `--cf MIN`) to recover the real
# extremes from PVE's own RRD consolidation. The
# extra calls add ~150 ms — only on week/month and
# only when the chart loads, so the overhead is small.
def _values_from(items, field_key, scale=1.0):
return [item[field_key] * scale for item in items
if isinstance(item.get(field_key), (int, float))
and not isinstance(item[field_key], bool)
and item[field_key] is not None]
def _stats_native(field_key, scale=1.0):
values = _values_from(rrd_data, field_key, scale)
if not values:
return None
return {
'avg': sum(values) / len(values),
'max': max(values),
'min': min(values),
}
def _pvesh_rrd(cf):
"""One extra pvesh call with a non-default CF.
Returns the parsed list or None on any failure caller
falls back to the AVG-based numbers."""
try:
extra = subprocess.run(
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
'--timeframe', timeframe, '--cf', cf,
'--output-format', 'json'],
capture_output=True, text=True, timeout=10,
)
if extra.returncode == 0 and extra.stdout:
return json.loads(extra.stdout)
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
pass
return None
def _build_stats(field_key, scale=1.0):
native = _stats_native(field_key, scale)
if native is None:
return None
# On week/month, the points we already have are AVG.
# Try to upgrade max/min to the real RRD extremes.
if timeframe in ('week', 'month'):
cf_max = _pvesh_rrd('MAX')
if cf_max:
vals = _values_from(cf_max, field_key, scale)
if vals:
native['max'] = max(vals)
cf_min = _pvesh_rrd('MIN')
if cf_min:
vals = _values_from(cf_min, field_key, scale)
if vals:
native['min'] = min(vals)
return native
period_stats = {
# cpu: RRD stores fraction 0-1, surface as %.
'cpu': _build_stats('cpu', scale=100.0),
# memory_used: bytes → GB so units match the chart.
'memory_used': _build_stats('memused', scale=1 / (1024 ** 3)),
}
# 24h downsampling: RRD returns ~1440 minute-level points which
# plots as a dense thicket of vertical spikes. Group into 5-min
# buckets and average each numeric field — same shape that
# `get_temperature_history` uses for its 24h view so the look
# is consistent across the dashboard's 24h charts.
if timeframe == 'day' and rrd_data:
bucket_seconds = 300 # 5-min
buckets = {}
for item in rrd_data:
t = item.get('time')
if t is None:
continue
bk = (int(t) // bucket_seconds) * bucket_seconds
if bk not in buckets:
buckets[bk] = {'_count': 0, '_sums': {}}
b = buckets[bk]
b['_count'] += 1
for k, v in item.items():
if k == 'time' or not isinstance(v, (int, float)) or isinstance(v, bool):
continue
b['_sums'][k] = b['_sums'].get(k, 0) + v
rrd_data = []
for bk in sorted(buckets.keys()):
b = buckets[bk]
point = {'time': bk}
for k, total in b['_sums'].items():
point[k] = total / b['_count']
rrd_data.append(point)
payload = {
'node': local_node,
'timeframe': timeframe,
'data': rrd_data,
# AVG/MAX/MIN computed over the raw (pre-downsampling)
# points so the chart header captures real per-minute
# extremes even on multi-day timeframes.
'period_stats': period_stats,
}
_node_metrics_cache_set(timeframe, payload)
return jsonify(payload)
# Note: the old `else` branch that handled rrd_result.returncode != 0
# was removed — the early-return block above now catches every
# non-zero exit BEFORE we ever attempt json.loads(), so reaching
# this point with returncode != 0 is impossible.
except json.JSONDecodeError:
# pvesh returned invalid JSON - likely empty RRD
return jsonify({
'error': 'Proxmox RRD data not available',
'details': 'pvesh returned non-JSON output. The RRD database is likely empty (fresh install where pvestatd has not run yet) or the rrdcached daemon is down.',
'suggestion': 'systemctl restart rrdcached pvestatd ; wait ~5 min and reload.',
}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
return jsonify(_get_node_metrics_payload(timeframe))
except _NodeMetricsError as exc:
response = jsonify(exc.payload)
response.status_code = 503
response.headers['Retry-After'] = str(int(_NODE_METRICS_RETRY_DELAY))
return response
@app.route('/api/logs/counts', methods=['GET'])
@require_auth
@@ -13572,18 +13402,10 @@ def api_lxc_updates_detection_set():
def api_vm_apps_get(vmid):
try:
import lxc_apps
cached = _vm_cache_get(_vm_apps_cache, vmid, _VM_APPS_TTL)
if cached is not None:
# Annotated on the way out, never on the way in: this cache is
# invalidated by events, not by time, and the Docker inventory it
# reads is built asynchronously. Annotating before storing would
# freeze whatever was known at first read — for an app registered
# before the first scan, permanently no available version.
lxc_apps.annotate_delegated_apps(cached.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
return jsonify(cached)
sidecar = lxc_apps.load_sidecar(vmid)
payload = sidecar if sidecar else {'vmid': vmid, 'apps': []}
_vm_cache_put(_vm_apps_cache, vmid, payload)
# Read the shared, file-versioned snapshot; annotate only on the way
# out so asynchronously collected Docker metadata remains current.
lxc_apps.annotate_delegated_apps(payload.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
return jsonify(payload)
except Exception as e:
@@ -13599,7 +13421,6 @@ def api_vm_apps_add(vmid):
ok, result = lxc_apps.add_app(vmid, payload)
if not ok:
return jsonify({'error': result}), 400
_vm_cache_put(_vm_apps_cache, vmid, result)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -13635,7 +13456,6 @@ def api_vm_apps_update(vmid, app_id):
if not ok:
code = 404 if 'not found' in str(result).lower() else 400
return jsonify({'error': result}), code
_vm_cache_put(_vm_apps_cache, vmid, result)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -13648,7 +13468,6 @@ def api_vm_apps_delete_one(vmid, app_id):
import lxc_apps
ok = lxc_apps.delete_app(vmid, app_id)
sidecar = lxc_apps.load_sidecar(vmid) or {'vmid': vmid, 'apps': []}
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
return jsonify({**sidecar, 'success': ok, 'app_id': app_id}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -13661,7 +13480,6 @@ def api_vm_apps_delete_all(vmid):
import lxc_apps
ok = lxc_apps.delete_all(vmid)
sidecar = {'vmid': vmid, 'apps': []}
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
return jsonify({**sidecar, 'success': ok}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -13675,7 +13493,6 @@ def api_vm_apps_check_one(vmid, app_id):
sidecar = lxc_apps.check_app(vmid, app_id, force=True)
if not sidecar:
return jsonify({'error': 'app not found'}), 404
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
return jsonify(sidecar)
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -13689,7 +13506,6 @@ def api_vm_apps_check_all(vmid):
sidecar = lxc_apps.check_all(vmid, force=True)
if not sidecar:
sidecar = {'vmid': vmid, 'apps': []}
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
return jsonify(sidecar)
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -14077,7 +13893,6 @@ def api_vm_apps_dismiss(vmid):
ok, result = lxc_apps.set_dismissed_slug(vmid, slug, dismissed)
if not ok:
return jsonify({'error': result}), 400
_vm_cache_put(_vm_apps_cache, vmid, result)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -14398,11 +14213,8 @@ def _finalize_lxc_update(
vmid,
[item[4:] for item in requested if item.startswith('app:')],
)
refreshed_sidecar = lxc_apps.check_all(vmid, force=True)
refreshed_sidecar = refreshed_sidecar or {'vmid': vmid, 'apps': []}
_vm_cache_put(_vm_apps_cache, vmid, refreshed_sidecar)
lxc_apps.check_all(vmid, force=True)
except Exception as exc:
_vm_cache_invalidate(vmid, _vm_apps_cache)
verification_errors.append(f'application refresh failed: {exc}')
docker_attempted = refresh_docker_inventory or any(
target_id.startswith('docker-') for target_id in requested
@@ -15109,18 +14921,19 @@ def api_vms_modal_cache_all():
frontend replace the current 84-request warm-up (4 endpoints ×
~21 guests) with a single fetch on page load.
Reads **exclusively** from the in-memory caches populated by
Reads modal caches populated by
the backend prewarmer (`_vm_modal_prewarmer_loop`). Never falls
through to a live handler call that would let a single cold
guest block the whole bulk response for 10-20s. If a guest is
not yet cached the corresponding field is `null` and the client
fetches that one endpoint dirigido on demand.
guest block the whole bulk response for 10-20s. Registered apps use
the shared sidecar snapshot, checking only local file metadata for changes.
If a guest's other modal data is not yet cached, that field is `null`
and the client fetches the corresponding endpoint on demand.
Trade-off: for the ~20-70s window right after `systemctl
restart proxmenux-monitor` some fields come back `null`; the
client transparently falls back to per-endpoint fetches for
those. Once the initial warm-up finishes the entire response
is served from dict reads (<20ms even with 30+ guests).
those. Once the initial warm-up finishes, registered apps reuse their
parsed snapshots and the other fields are served from dict reads.
Response shape:
{
@@ -15150,7 +14963,8 @@ def api_vms_modal_cache_all():
'backups': _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL),
}
if vm_type == 'lxc':
entry['apps'] = _vm_cache_get(_vm_apps_cache, vmid, _VM_APPS_TTL)
import lxc_apps
entry['apps'] = lxc_apps.load_sidecar(vmid) or {'vmid': vmid, 'apps': []}
entry['suggestions'] = _vm_cache_get(
_vm_app_suggestions_cache, vmid,
_VM_APP_SUGGESTIONS_TTL,
@@ -21887,7 +21701,8 @@ def _compose_scheduled_update_command(vmid: int, target: str, targets: list[str]
and cmd == _DOCKER_ENGINE_INTEGRATED_COMMAND
)
if cmd and not is_integrated_docker_command:
parts.append(cmd)
# Protect each legacy launcher before joining the multi-app plan.
parts.append(lxc_apps.protect_download_update_command(cmd))
selected_projects = {
item.split(":", 1)[1]
for item in targets
@@ -21961,7 +21776,7 @@ def _scheduled_helper_enabled(vmid: int, target: str, targets: list[str]) -> boo
# conservatively instead of running both methods for the same app.
if any((app.get("update_command") or "").strip() for app in matching_apps):
return False
return True
return lxc_apps.helper_update_selected(vmid, helper_slug, targets)
def _resolve_bulk_update_plan(vmid: int, targets: list[str]) -> dict:
@@ -22004,6 +21819,7 @@ def _resolve_bulk_update_plan(vmid: int, targets: list[str]) -> dict:
def add_command(command: str) -> None:
command = str(command or '').strip()
command = lxc_apps.protect_download_update_command(command)
if command and command not in commands:
commands.append(command)
@@ -22041,7 +21857,8 @@ def _resolve_bulk_update_plan(vmid: int, targets: list[str]) -> dict:
command = str(app.get('update_command') or '').strip()
if command:
add_command(command)
elif helper_enabled and app.get('helper_slug'):
elif (helper_enabled and app.get('update_method') == 'helper'
and _scheduled_helper_enabled(vmid, 'app', [target_id])):
run_helper = True
else:
unavailable.append({'target': target_id, 'reason': 'no executable update method is available'})
@@ -22249,6 +22066,36 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
registered_apps = (lxc_apps._read_sidecar(vmid) or {}).get("apps") or []
except Exception:
registered_apps = []
# A saved schedule can outlive an application's updater choice. Keep
# its configuration, but explicitly report unavailable targets instead
# of silently treating an OS-only/no-op run as a complete app update.
helper_ready = _scheduled_helper_enabled(vmid, 'app', targets)
available_app_ids = {
str(app.get('id')) for app in registered_apps
if not app.get('managed_oci_app_id') and (
(app.get('update_command') or '').strip()
or app.get('helper_slug') == 'docker'
or (helper_ready and app.get('update_method') == 'helper'
and _scheduled_helper_enabled(vmid, 'app', [f"app:{app.get('id')}"]))
)
}
filtered_targets = []
for value in targets:
if value == 'apps':
eligible = [f"app:{app.get('id')}" for app in registered_apps
if str(app.get('id')) in available_app_ids
and app.get('helper_slug') != 'docker']
if eligible:
filtered_targets.extend(eligible)
else:
deferred_targets.append(value)
reasons.append('no application update method has been selected or is available')
elif value.startswith('app:') and value.split(':', 1)[1] not in available_app_ids:
deferred_targets.append(value)
reasons.append(f'{value}: update method is not selected or no longer available')
else:
filtered_targets.append(value)
targets = list(dict.fromkeys(filtered_targets))
if any(value.startswith("docker-") for value in targets):
docker_registered = any(app.get("helper_slug") == "docker" for app in registered_apps)
if not docker_registered:
@@ -22721,22 +22568,16 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] app-updates startup emitter failed to arm: {e}")
# ── Node-metrics Prewarmer ──
# Keeps `_NODE_METRICS_CACHE` hot for every timeframe (hour / day /
# week / month / year) on a 30 s cadence, so the Overview page's
# CPU + memory charts never wait on `pvesh get rrddata` when the
# user opens the dashboard. Cache TTL is 60 s; the loop refreshes
# every 30 s, giving 30 s of headroom against transient pvesh
# latency.
# Prewarm only the Overview's default day range; other ranges are lazy.
try:
metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer')
metrics_thread.start()
print("[ProxMenux] Node-metrics prewarmer started (30s interval)")
print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)")
except Exception as e:
print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}")
# ── VM/CT modal-cache prewarmer ──
# Keeps _vm_details_cache / _vm_backups_cache / _vm_apps_cache /
# Keeps _vm_details_cache / _vm_backups_cache / app snapshots /
# _vm_schedule_cache warm from the backend so the "Loading
# configuration..." message never appears on modal open, even
# after the tab has been closed for minutes. The React-side
+147 -10
View File
@@ -26,6 +26,7 @@
from __future__ import annotations
import datetime
import copy
import concurrent.futures
import hashlib
import json
@@ -418,15 +419,43 @@ def _now_iso() -> str:
return datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
_sidecar_cache: dict = {}
_sidecar_cache_lock = threading.RLock()
_sidecar_revision = 0
def _sidecar_signature(stat) -> tuple:
return (stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size)
def _publish_sidecar_snapshot(path: str, data: dict, signature: tuple) -> dict:
"""Publish under _sidecar_cache_lock; revisions exist only in memory."""
global _sidecar_revision
_sidecar_revision = max(_sidecar_revision + 1, int(time.time() * 1000))
snapshot = _migrate_legacy(copy.deepcopy(data))
_migrate_update_methods(snapshot)
snapshot['_revision'] = _sidecar_revision
_sidecar_cache[path] = (signature, snapshot)
return snapshot
def _read_sidecar(vmid) -> Optional[dict]:
path = _sidecar_path(vmid)
try:
with open(path) as f:
data = json.load(f)
if isinstance(data, dict):
return _migrate_legacy(data)
except (FileNotFoundError, json.JSONDecodeError, OSError):
pass
with _sidecar_cache_lock:
try:
signature = _sidecar_signature(os.stat(path))
cached = _sidecar_cache.get(path)
if cached is not None and cached[0] == signature:
return copy.deepcopy(cached[1])
with open(path) as f:
data = json.load(f)
signature = _sidecar_signature(os.fstat(f.fileno()))
if isinstance(data, dict):
snapshot = _publish_sidecar_snapshot(path, data, signature)
return copy.deepcopy(snapshot)
except (FileNotFoundError, json.JSONDecodeError, OSError):
pass
_sidecar_cache.pop(path, None)
return None
@@ -466,15 +495,107 @@ def _migrate_legacy(data: dict) -> dict:
"updated_at": data.get("updated_at") or _now_iso()}
def _migrate_update_methods(data: dict) -> None:
"""Preserve saved choices, never turn detection into updater consent.
Old commands and explicitly saved bulk/enabled schedule selections keep
working. New registrations always carry update_method, so an old `apps`
wildcard cannot opt newly registered applications into Helper-Scripts.
Projection is read-only; the next normal sidecar write persists it.
"""
schedule = data.get("schedule") or {}
selected = set((data.get("bulk_update") or {}).get("targets") or [])
if schedule.get("enabled"):
targets = schedule.get("targets")
if not targets:
targets = ["apps"] if schedule.get("target", "both") in ("app", "both") else []
selected.update(targets)
for app in data.get("apps") or []:
if "update_method" in app:
continue
if (app.get("update_command") or "").strip():
app["update_method"] = "custom"
elif (app.get("helper_slug") and app.get("helper_slug") not in ("docker", "adguard")
and ("apps" in selected or f"app:{app.get('id')}" in selected)):
app["update_method"] = "helper"
else:
app["update_method"] = "none"
def protect_download_update_command(command: str) -> str:
"""Guard the historical downloaded-shell launcher at execution time.
Only a literal, standalone wget/curl + shell -c launcher is recognised.
Other custom commands are returned byte-for-byte, never evaluated here.
Saved configuration is not rewritten. Grouping preserves && composition.
"""
launcher = re.fullmatch(
r'''\s*(?P<prefix>PHS_SILENT=[01][ \t]+)?(?P<shell>(?:/bin/|/usr/bin/)?(?:bash|sh))[ \t]+-c[ \t]+"\$\((?P<fetch>[^\n]+)\)"\s*''',
command,
)
if not launcher:
return command
fetch = re.fullmatch(
r'''(?P<tool>wget|curl)[ \t]+(?P<flags>-qLO[ \t]+-|-qO[ \t]+-|-qO-|-fsSL|-fSL)[ \t]+(?P<quote>['"]?)(?P<url>https?://[A-Za-z0-9_./:%?=&+#@,~!;-]+)(?P=quote)''',
launcher['fetch'],
)
if not fetch:
return command
# Require the original shell token to be literal too. Unquoted shell
# operators or glob patterns are not this known launcher format.
if not fetch['quote'] and any(c in fetch['url'] for c in '&;?'):
return command
flags = fetch['flags'].split()
if ((fetch['tool'] == 'wget' and flags not in (['-qLO', '-'], ['-qO', '-'], ['-qO-']))
or (fetch['tool'] == 'curl' and flags not in (['-fsSL'], ['-fSL']))):
return command
fetch_command = shlex.join([fetch['tool'], *flags, fetch['url']])
invocation = (launcher['prefix'] or '') + launcher['shell']
return (
'(\n'
f'_proxmenux_updater=$({fetch_command}) || {{\n'
' echo "ERROR: updater download failed; nothing was executed." >&2\n'
' exit 1\n'
'}\n'
'[ -n "$_proxmenux_updater" ] || {\n'
' echo "ERROR: downloaded updater is empty; nothing was executed." >&2\n'
' exit 1\n'
'}\n'
f'{invocation} -c "$_proxmenux_updater"\n'
')'
)
def helper_update_selected(vmid, slug: str, targets=None) -> bool:
"""Execution-time consent check, shared with the shell runner.
Wrapper provenance is independently verified by the caller. Duplicate
registrations with different choices must not run a CT-wide helper.
"""
apps = (_read_sidecar(vmid) or {}).get("apps") or []
matching = [app for app in apps if app.get("helper_slug") == slug
and not app.get("managed_oci_app_id")]
if not matching or any(app.get("update_method") != "helper"
or (app.get("update_command") or "").strip() for app in matching):
return False
if targets is None or "apps" in targets:
return True
return any(f"app:{app.get('id')}" in targets for app in matching)
def _write_sidecar(vmid, data: dict) -> bool:
_migrate_update_methods(data)
_ensure_dir()
path = _sidecar_path(vmid)
tmp = f"{path}.tmp.{os.getpid()}"
try:
with open(tmp, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
json.dump({k: v for k, v in data.items() if k != '_revision'}, f, indent=2, sort_keys=True)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
with _sidecar_cache_lock:
os.replace(tmp, path)
snapshot = _publish_sidecar_snapshot(path, data, _sidecar_signature(os.stat(path)))
data['_revision'] = snapshot['_revision']
return True
except OSError as e:
print(f"[ProxMenux] lxc_apps: could not write sidecar {path}: {e}")
@@ -1092,6 +1213,17 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
# two-step strategy payloads and normalize them on write.
conf["update_strategy"] = "custom_override"
method = payload.get("update_method", "custom" if conf.get("update_command") else "none")
if method not in ("none", "helper", "custom"):
return _err("update_method must be none, helper or custom")
if method == "custom" and not conf.get("update_command"):
return _err("update_command is required for update_method=custom")
if method != "custom" and conf.get("update_command"):
return _err("update_command is only allowed for update_method=custom")
if method == "helper" and (not hs or hs in ("docker", "adguard")):
return _err("a supported helper_slug is required for update_method=helper")
conf["update_method"] = method
# Optional per-app dismiss flag for the "no update method defined"
# notice shown in the Updates tab. Only affects the notice card;
# the App tab keeps its purple update signal regardless.
@@ -4057,6 +4189,7 @@ def _summarise_app(app: dict) -> dict:
"health_path": app.get("health_path"),
"installed_version": state.get("installed_version"),
"latest_version": state.get("latest_version"),
"latest_published_at": state.get("latest_published_at"),
"update_available": state.get("update_available"),
"error": state.get("error"),
"checked_at": state.get("checked_at"),
@@ -4066,6 +4199,7 @@ def _summarise_app(app: dict) -> dict:
# it) and whether the "no method" notice is suppressed for
# this app.
"update_command": app.get("update_command") or "",
"update_method": app.get("update_method", "custom" if app.get("update_command") else "none"),
# Compatibility field for older clients. The only supported
# strategy is now replacement; legacy sidecars are normalized
# in the API even before their next write.
@@ -4273,7 +4407,10 @@ def get_active_apps() -> dict:
apps = sidecar.get("apps") or []
if not apps:
continue
out[str(vmid)] = [_summarise_app(a) for a in apps]
out[str(vmid)] = [
{**_summarise_app(a), 'state_revision': sidecar.get('_revision')}
for a in apps
]
return out
+36 -16
View File
@@ -123,6 +123,20 @@ def _read_lxc_config(vmid: str) -> list[dict[str, Any]]:
return out
def _mount_target_key(target: str) -> str:
"""Return a stable comparison key for a CT-side mount target.
Proxmox config accepts a trailing slash in ``mp=/path/`` while
``/proc/<pid>/mounts`` reports the realised target as ``/path``.
They name the same mount point, so comparisons must not treat the
spelling difference as a runtime divergence. Keep the root path
intact: stripping its only slash would turn it into an empty key.
"""
if target == "/":
return target
return target.rstrip("/")
# ---------------------------------------------------------------------------
# Type classification + source resolution
# ---------------------------------------------------------------------------
@@ -605,14 +619,14 @@ def get_lxc_mount_points_static(vmid: str) -> dict[str, Any]:
if running and host_pid:
try:
config_targets = {
entry.get("target", "")
_mount_target_key(entry.get("target", ""))
for entry in config_entries
if entry.get("target")
}
for rt in _read_ct_proc_mounts(host_pid):
if not _REMOTE_FS_RE.match(rt.get("rt_fstype", "")):
continue
if rt.get("rt_target") in config_targets:
if _mount_target_key(rt.get("rt_target", "")) in config_targets:
continue
ad_hoc_hint_count += 1
except Exception:
@@ -658,7 +672,9 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
# 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}
rt_by_target: dict[str, dict[str, Any]] = {
_mount_target_key(m["rt_target"]): m for m in rt_mounts
}
runtime_by_target: dict[str, dict[str, Any]] = {}
matched_targets: set[str] = set()
@@ -673,9 +689,10 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
host_pid=host_pid if running else "",
target=tgt,
)
live_target = bool(running and tgt and tgt in rt_by_target)
target_key = _mount_target_key(tgt)
live_target = bool(running and tgt and target_key in rt_by_target)
health = _stat_via_host(host_pid, tgt) if live_target else None
return entry, capacity, live_target, health
return entry, capacity, target_key, live_target, health
if config_entries:
max_workers = max(2, min(8, len(config_entries)))
@@ -684,11 +701,11 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
else:
gathered = []
for entry, cap, live_target, health in gathered:
for entry, cap, target_key, live_target, health in gathered:
target = entry.get("target", "")
rt_item: dict[str, Any] = {**cap}
if live_target:
rt = rt_by_target[target]
rt = rt_by_target[target_key]
rt_item.update({
"runtime_mounted": True,
"runtime_source": rt["rt_source"],
@@ -698,7 +715,7 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
"runtime_reachable": health["reachable"],
"runtime_error": health["error"],
})
matched_targets.add(target)
matched_targets.add(target_key)
elif running:
rt_item["runtime_mounted"] = False
rt_item["runtime_error"] = "configured but not mounted"
@@ -712,7 +729,7 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
if running:
ad_hoc_candidates = [
rt for rt in rt_mounts
if rt["rt_target"] not in matched_targets
if _mount_target_key(rt["rt_target"]) not in matched_targets
and _REMOTE_FS_RE.match(rt["rt_fstype"])
]
if ad_hoc_candidates:
@@ -776,7 +793,9 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
# Index runtime mounts by their CT-side target path so we can
# match a config entry to its current realised state in O(1).
rt_by_target: dict[str, dict[str, Any]] = {m["rt_target"]: m for m in rt_mounts}
rt_by_target: dict[str, dict[str, Any]] = {
_mount_target_key(m["rt_target"]): m for m in rt_mounts
}
out: list[dict[str, Any]] = []
matched_targets: set[str] = set()
@@ -801,15 +820,16 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
target=tgt,
)
host_src = _host_source_state(src)
live_target = bool(running and tgt and tgt in rt_by_target)
target_key = _mount_target_key(tgt)
live_target = bool(running and tgt and target_key in rt_by_target)
health = _stat_via_host(host_pid, tgt) if live_target else None
return entry, classification, capacity, host_src, live_target, health
return entry, classification, capacity, host_src, target_key, live_target, health
max_workers = max(2, min(8, len(config_entries) or 1))
with ThreadPoolExecutor(max_workers=max_workers) as pool:
gathered = list(pool.map(_gather_one, config_entries))
for entry, cls, cap, host_src, live_target, health in gathered:
for entry, cls, cap, host_src, target_key, live_target, health in gathered:
source = entry.get("source", "")
target = entry.get("target", "")
@@ -830,7 +850,7 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
# Runtime enrichment when CT is up.
if live_target:
rt = rt_by_target[target]
rt = rt_by_target[target_key]
item.update({
"runtime_mounted": True,
"runtime_source": rt["rt_source"],
@@ -840,7 +860,7 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
"runtime_reachable": health["reachable"],
"runtime_error": health["error"],
})
matched_targets.add(target)
matched_targets.add(target_key)
elif running:
# CT is running but the configured mount isn't in
# /proc/<pid>/mounts — divergence. Could be a startup
@@ -860,7 +880,7 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
if running:
ad_hoc_candidates = [
rt for rt in rt_mounts
if rt["rt_target"] not in matched_targets
if _mount_target_key(rt["rt_target"]) not in matched_targets
and _REMOTE_FS_RE.match(rt["rt_fstype"])
]
# Same parallelisation as the configured-mp loop: stat'ing
+24
View File
@@ -2586,6 +2586,11 @@ class PollingCollector:
# once for each genuinely new available version. The persistent
# history is stored in updates_available.json beside the scan.
self._last_post_install_check = 0
# Re-open that announcement once per service start, so a host
# carrying optimizations it never applied surfaces them again
# after a ProxMenux update or a node reboot instead of staying
# silent forever. Consumed by the first check cycle.
self._post_install_startup_reset_pending = True
# Sprint 14.7: fingerprint (item_id → latest_version) of the
# last managed-installs update notification, across all types
# in the registry. A new notification fires when the
@@ -3511,6 +3516,22 @@ class PollingCollector:
# ── Post-install function updates check (Sprint 12D) ────────────
def _reset_post_install_announcements(self):
"""Re-open the optimization announcement after a service start.
``post_install_update`` sits behind a second gate the other
update events don't have: a per-version history that keeps a
pending optimization from being announced twice. The manager's
``_EVENT_TYPES_RESET_ON_START`` already clears the delivery
cooldown, so only that history has to be dropped here for the
first cycle to report whatever is still pending.
"""
try:
import post_install_versions
post_install_versions.reset_notified_versions()
except Exception as e:
print(f"[PollingCollector] post-install history reset failed: {e}")
def _check_post_install_updates(self):
"""Notify the operator when post-install functions have new versions.
@@ -3522,6 +3543,9 @@ class PollingCollector:
shrinks the pending set and must not produce a second notification.
"""
now = time.time()
if self._post_install_startup_reset_pending:
self._post_install_startup_reset_pending = False
self._reset_post_install_announcements()
if now - self._last_post_install_check < self.UPDATE_CHECK_INTERVAL:
return
self._last_post_install_check = now
+28 -6
View File
@@ -1608,7 +1608,7 @@ class NotificationManager:
showed zero digest entries even when the schedule was firing
(issue #233).
"""
host = _hostname(self._config)
host = _resolve_display_hostname(self._config)
summary_title = (
f"{host}: 24h summary ({now.strftime('%Y-%m-%d %H:%M')})"
)
@@ -1847,23 +1847,44 @@ class NotificationManager:
if not rows:
return
host = _hostname(self._config)
host = _resolve_display_hostname(self._config)
summary_title = (
f"{host}: {len(rows)} events buffered during Quiet Hours"
)
summary_body = self._compose_digest_body(rows)
result: dict = {'success': False, 'error': ''}
try:
channel.send(summary_title, summary_body, severity='INFO',
data={'_quiet_hours_summary': True, '_count': len(rows)})
result = channel.send(
summary_title, summary_body, severity='INFO',
data={'_quiet_hours_summary': True, '_count': len(rows)},
) or result
except Exception as e:
print(f"[NotificationManager] quiet send failed for "
f"{ch_name}: {e}")
return
result = {'success': False, 'error': str(e)}
if result.get('success'):
self._stats['total_sent'] += 1
self._stats['last_sent_at'] = datetime.now().isoformat()
else:
self._stats['total_errors'] += 1
# Mirrors the digest path: the release is a real delivery, so it
# belongs in the history and the counters the operator reads.
self._record_history(
'quiet_hours', ch_name, summary_title, summary_body, 'INFO',
result.get('success', False), result.get('error', '') or '',
'quiet_scheduler',
)
# Only drop the rows after a successful send so a transient
# transport failure (Telegram timeout, SMTP outage) doesn't
# lose the user's overnight context.
# lose the user's overnight context. A channel reporting failure
# without raising counts as a failure here too — otherwise the
# buffer is wiped for a summary the operator never received.
if not result.get('success'):
return
try:
ids = [r[0] for r in rows]
conn = sqlite3.connect(str(DB_PATH), timeout=10)
@@ -2082,6 +2103,7 @@ class NotificationManager:
'secure_gateway_update_available',
'app_update_available',
'docker_stack_update_available',
'post_install_update',
# Security events that must not be silenced by stale cooldowns
# following a Monitor reinstall (Pedro Rico, 19/05).
'auth_fail',
+23
View File
@@ -434,6 +434,29 @@ def scan(persist: bool = True) -> dict[str, Any]:
return snapshot
def reset_notified_versions() -> None:
"""Forget which optimization versions have already been announced.
Each version is announced once, so a host that never applies a
pending optimization would otherwise stay silent about it forever.
Clearing the history reopens that single announcement.
The caller owns the timing: the notification collector runs this on
its first cycle after a service start, together with clearing the
matching delivery cooldown, because forgetting the history while the
cooldown still suppresses delivery would consume the announcement
without ever sending it.
"""
try:
with _cache_lock:
scanned_at = float(_cache.get("scanned_at", 0.0) or 0.0)
updates = list(_cache.get("updates", []))
_write_persisted_snapshot(scanned_at, updates, {})
except OSError as e:
# Read-only host: de-duplication stays best-effort, as elsewhere.
print(f"[post_install_versions] could not reset notified versions: {e}")
def scan_at_startup() -> dict[str, Any]:
"""Convenience wrapper called from flask_server startup.
+47 -4
View File
@@ -21,7 +21,8 @@
# intentionally use sh -c with a variable
# payload — the threat model matches "user
# typed it via pct exec themselves"; ProxMenux
# does not compose or interpret the command.
# preserves arbitrary commands. Historical downloaded
# shell launchers get an explicit download failure guard.
# ALLOW_HELPER_WITH_CUSTOM — "1" only for an explicit multi-app plan
# where RUN_HELPER belongs to one registered app and
# UPDATE_COMMAND contains other registered apps. The
@@ -38,7 +39,7 @@
# 1 CT not found on this node
# 2 CT could not be started
# 3 pre-update backup failed (abort so the user still has a rollback)
# 4 OS update failed OR OS family not supported for automated updates
# 4 OS/app update failed OR OS family not supported for automated updates
# 5 TARGET=app requested but no update method (neither UPDATE_COMMAND
# nor explicitly-enabled verified helper) available in the CT
# 6 post-update restart failed
@@ -273,6 +274,25 @@ if [[ "$TARGET" == "app" || "$TARGET" == "both" ]]; then
APP_FAILED=1
fi
else
# Detection is not consent. Recheck the saved per-app choice here too,
# so a stale browser/plan cannot run a helper after it was deselected.
if ! python3 - "$VMID" "$RESOLVED_SLUG" <<'PY'
import json
import os
import sys
sys.path.insert(0, '/usr/local/share/proxmenux/monitor-app/usr/bin')
import lxc_apps
targets = json.loads(os.environ.get('REQUESTED_TARGETS_JSON') or '[]')
if not isinstance(targets, list):
raise SystemExit(1)
raise SystemExit(0 if lxc_apps.helper_update_selected(
sys.argv[1], sys.argv[2], targets or None,
) else 1)
PY
then
echo "ERROR: Helper-Scripts has not been selected for this application. Configure its update method first." >&2
exit 5
fi
# Never execute the arbitrary URL embedded in the CT. The slug is
# constrained by the parser; fetch the canonical upstream path.
UPDATE_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${RESOLVED_SLUG}.sh"
@@ -295,7 +315,18 @@ if [[ "$TARGET" == "app" || "$TARGET" == "both" ]]; then
echo "ERROR: CT $VMID has neither wget nor curl — cannot fetch the helper." >&2
APP_FAILED=1
else
if ! pct exec "$VMID" -- bash -c "PHS_SILENT=1 bash -c \"\$($IN_CT_FETCH)\""; then
# Check the download before invoking bash: bash -c "$(failed wget)"
# otherwise executes an empty string and falsely returns success.
if ! pct exec "$VMID" -- bash -c "
_proxmenux_updater=\$($IN_CT_FETCH) || {
echo 'ERROR: updater download failed; nothing was executed.' >&2
exit 1
}
[ -n \"\$_proxmenux_updater\" ] || {
echo 'ERROR: downloaded updater is empty; nothing was executed.' >&2
exit 1
}
PHS_SILENT=1 bash -c \"\$_proxmenux_updater\""; then
echo "ERROR: community-scripts helper returned non-zero." >&2
APP_FAILED=1
fi
@@ -307,7 +338,19 @@ if [[ "$TARGET" == "app" || "$TARGET" == "both" ]]; then
if [[ -n "$UPDATE_COMMAND" ]]; then
echo "--- Running user-defined update command ---"
echo "\$ $UPDATE_COMMAND"
if ! pct exec "$VMID" -- sh -c "$UPDATE_COMMAND"; then
# Also covers a legacy command submitted by a browser opened before the
# upgrade. Bulk/scheduled plans protect each constituent command upstream.
if ! PREPARED_COMMAND=$(UPDATE_COMMAND="$UPDATE_COMMAND" python3 - protect-update-command <<'PY'
import os
import sys
sys.path.insert(0, '/usr/local/share/proxmenux/monitor-app/usr/bin')
from lxc_apps import protect_download_update_command
sys.stdout.write(protect_download_update_command(os.environ['UPDATE_COMMAND']))
PY
); then
echo "ERROR: could not prepare the update command; nothing was executed." >&2
APP_FAILED=1
elif ! pct exec "$VMID" -- sh -c "$PREPARED_COMMAND"; then
echo "ERROR: user-defined update command returned non-zero." >&2
APP_FAILED=1
fi
+164
View File
@@ -0,0 +1,164 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT=$(cd "$(dirname "$0")/../.." && pwd)
RUNNER="$REPO_ROOT/scripts/lxc/apply_updates.sh"
TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/proxmenux-update-test.XXXXXX")
trap 'rm -rf "$TEST_ROOT"' EXIT
MOCK_BIN="$TEST_ROOT/bin"
mkdir -p "$MOCK_BIN" "$TEST_ROOT/locks"
cat >"$MOCK_BIN/pct" <<'EOF'
#!/bin/bash
echo "pct $*" >>"$MOCK_LOG"
case "$1" in
list)
printf 'VMID Status Name\n101 %s test\n' "${MOCK_INITIAL_STATE:-running}"
;;
status)
printf 'status: %s\n' "${MOCK_INITIAL_STATE:-running}"
;;
start|shutdown|reboot)
;;
exec)
shift 3
joined="$*"
case "$joined" in
*'grep -E "^ID="'*) echo 'ID=debian' ;;
'test -f /usr/bin/update') [[ "${MOCK_HAS_WRAPPER:-1}" == "1" ]] ;;
'cat /usr/bin/update')
case "${MOCK_WRAPPER_FORMAT:-legacy}" in
modern)
printf '%s\n' \
'#!/usr/bin/env bash' \
'# Regenerated on install and on every successful update.' \
"export SCRIPT_SLUG=\"${MOCK_HELPER_SLUG:-jellyfin}\"" \
"export UPDATE_SCRIPT_NAME=\"${MOCK_HELPER_SLUG:-jellyfin}\"" \
'bash -c "$(curl -fsSL "${COMMUNITY_SCRIPTS_URL}/ct/${UPDATE_SCRIPT_NAME}.sh")"'
;;
update-name)
printf "export UPDATE_SCRIPT_NAME='%s'\n" "${MOCK_HELPER_SLUG:-jellyfin}"
;;
unsafe)
printf '%s\n' 'export SCRIPT_SLUG="$(touch /tmp/proxmenux-unsafe)"'
;;
*)
echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${MOCK_HELPER_SLUG:-jellyfin}.sh)\""
;;
esac
;;
*'command -v wget'*) ;;
bash\ -c*) echo HELPER_EXEC >>"$MOCK_LOG" ;;
sh\ -c*)
echo CUSTOM_EXEC >>"$MOCK_LOG"
[[ "${MOCK_CUSTOM_FAIL:-0}" != "1" ]]
;;
env\ DEBIAN_FRONTEND=noninteractive*) echo OS_EXEC >>"$MOCK_LOG" ;;
esac
;;
esac
EOF
cat >"$MOCK_BIN/flock" <<'EOF'
#!/bin/bash
[[ "${MOCK_FLOCK_FAIL:-0}" != "1" ]]
EOF
cat >"$MOCK_BIN/python3" <<'EOF'
#!/bin/bash
cat >/dev/null
if [[ "${2:-}" == 'protect-update-command' ]]; then
printf '%s' "$UPDATE_COMMAND"
exit 0
fi
[[ "${MOCK_HELPER_SELECTED:-1}" == "1" ]]
EOF
cat >"$MOCK_BIN/sleep" <<'EOF'
#!/bin/bash
exit 0
EOF
cat >"$MOCK_BIN/vzdump" <<'EOF'
#!/bin/bash
echo "vzdump $*" >>"$MOCK_LOG"
EOF
chmod +x "$MOCK_BIN"/*
fail() { echo "FAIL: $*" >&2; exit 1; }
count() { grep -c "$1" "$MOCK_LOG" 2>/dev/null || true; }
run_case() {
local name=$1
shift
export MOCK_LOG="$TEST_ROOT/$name.log"
: >"$MOCK_LOG"
set +e
env PATH="$MOCK_BIN:$PATH" PROXMENUX_LOCK_DIR="$TEST_ROOT/locks" \
VMID=101 TARGET=app BACKUP=0 RESTART=0 "$@" bash "$RUNNER" \
>"$TEST_ROOT/$name.out" 2>&1
CASE_RC=$?
set -e
}
run_case helper_only RUN_HELPER=1 UPDATE_COMMAND=
[[ $CASE_RC -eq 0 ]] || fail "helper_only returned $CASE_RC"
[[ $(count HELPER_EXEC) -eq 1 ]] || fail "helper_only did not run helper exactly once"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "helper_only unexpectedly ran custom"
run_case helper_not_selected RUN_HELPER=1 UPDATE_COMMAND= MOCK_HELPER_SELECTED=0
[[ $CASE_RC -eq 5 ]] || fail "helper_not_selected expected 5, got $CASE_RC"
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "unselected helper was executed"
run_case modern_helper RUN_HELPER=1 UPDATE_COMMAND= MOCK_WRAPPER_FORMAT=modern MOCK_HELPER_SLUG=nginxproxymanager
[[ $CASE_RC -eq 0 ]] || fail "modern_helper returned $CASE_RC"
[[ $(count HELPER_EXEC) -eq 1 ]] || fail "modern_helper did not run helper exactly once"
grep -qF 'slug: nginxproxymanager' "$TEST_ROOT/modern_helper.out" \
|| fail "modern_helper did not resolve SCRIPT_SLUG"
run_case update_name_helper RUN_HELPER=1 UPDATE_COMMAND= MOCK_WRAPPER_FORMAT=update-name MOCK_HELPER_SLUG=qbittorrent
[[ $CASE_RC -eq 0 ]] || fail "update_name_helper returned $CASE_RC"
[[ $(count HELPER_EXEC) -eq 1 ]] || fail "update_name_helper did not run helper exactly once"
grep -qF 'slug: qbittorrent' "$TEST_ROOT/update_name_helper.out" \
|| fail "update_name_helper did not resolve UPDATE_SCRIPT_NAME"
run_case unsafe_wrapper RUN_HELPER=1 UPDATE_COMMAND= MOCK_WRAPPER_FORMAT=unsafe
[[ $CASE_RC -eq 5 ]] || fail "unsafe_wrapper expected 5, got $CASE_RC"
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "unsafe_wrapper ran helper"
[[ ! -e /tmp/proxmenux-unsafe ]] || fail "unsafe_wrapper evaluated CT content"
run_case custom_override RUN_HELPER=0 UPDATE_COMMAND='update-custom'
[[ $CASE_RC -eq 0 ]] || fail "custom_override returned $CASE_RC"
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "custom_override unexpectedly ran helper"
[[ $(count CUSTOM_EXEC) -eq 1 ]] || fail "custom_override did not run custom exactly once"
run_case custom_replaces_helper RUN_HELPER=1 UPDATE_COMMAND='replace-helper'
[[ $CASE_RC -eq 0 ]] || fail "custom_replaces_helper returned $CASE_RC"
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "custom_replaces_helper unexpectedly ran helper"
[[ $(count CUSTOM_EXEC) -eq 1 ]] || fail "custom_replaces_helper did not run custom exactly once"
grep -qF 'skipping Proxmox VE Helper-Scripts updater' "$TEST_ROOT/custom_replaces_helper.out" \
|| fail "custom_replaces_helper did not report the replacement rule"
run_case explicit_multi_app RUN_HELPER=1 ALLOW_HELPER_WITH_CUSTOM=1 UPDATE_COMMAND='update-another-app'
[[ $CASE_RC -eq 0 ]] || fail "explicit_multi_app returned $CASE_RC"
[[ $(count HELPER_EXEC) -eq 1 ]] || fail "explicit_multi_app did not run helper exactly once"
[[ $(count CUSTOM_EXEC) -eq 1 ]] || fail "explicit_multi_app did not run custom exactly once"
run_case missing_wrapper RUN_HELPER=1 UPDATE_COMMAND= MOCK_HAS_WRAPPER=0
[[ $CASE_RC -eq 5 ]] || fail "missing_wrapper expected 5, got $CASE_RC"
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "missing_wrapper ran helper"
run_case base_os_wrapper RUN_HELPER=1 UPDATE_COMMAND= MOCK_HELPER_SLUG=debian
[[ $CASE_RC -eq 5 ]] || fail "base_os_wrapper expected 5, got $CASE_RC"
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "base_os_wrapper ran helper"
run_case stopped_restore RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_INITIAL_STATE=stopped
[[ $CASE_RC -eq 0 ]] || fail "stopped_restore returned $CASE_RC"
[[ $(count 'pct start 101') -eq 1 ]] || fail "stopped CT was not started exactly once"
[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "stopped CT state was not restored"
run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1
[[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater"
echo "apply_updates.sh: all deterministic tests passed"
@@ -0,0 +1,163 @@
"""Run real shells and the real LXC runner against local download/guest fixtures."""
from __future__ import annotations
import ast
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import lxc_apps
URL = 'https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/odoo.sh2'
LEGACY = f'''PHS_SILENT=1 bash -c "$(wget -qLO - '{URL}')"'''
class DownloadGuardTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory(prefix='proxmenux-download-test-')
self.addCleanup(folder.cleanup)
self.folder = Path(folder.name)
self.bin = self.folder / 'bin'
self.bin.mkdir()
self.env = {**os.environ, 'PATH': f'{self.bin}:{os.environ["PATH"]}',
'PYTHONPATH': str(ROOT / 'AppImage/scripts'),
'FETCH_STATUS': '8', 'FETCH_BODY': '', 'VMID': '101',
'TARGET': 'app', 'BACKUP': '0', 'RESTART': '0',
'RUN_HELPER': '0', 'UPDATE_COMMAND': '',
'PROXMENUX_LOCK_DIR': str(self.folder)}
for tool in ('wget', 'curl'):
self.write(tool, '#!/bin/sh\nprintf "%s" "$FETCH_BODY"\nexit "$FETCH_STATUS"\n')
self.write('flock', '#!/bin/sh\nexit 0\n')
self.write('pct', '''#!/bin/bash
case "$1" in
list) printf 'VMID Status Name\\n101 running fixture\\n' ;;
status) echo 'status: running' ;;
exec)
shift 3
case "$*" in
*'/etc/os-release'*) echo 'ID=debian' ;;
'test -f /usr/bin/update') exit 0 ;;
'cat /usr/bin/update') echo 'SCRIPT_SLUG="odoo"' ;;
*) exec "$@" ;;
esac ;;
*) exit 90 ;;
esac
''')
# Only consent is stubbed. Preparation imports the real implementation.
self.write('python3', '#!/bin/bash\n'
'if [[ "$2" != "protect-update-command" ]]; then exit 0; fi\n'
# Preload the source under test, not a monitor already
# installed on the build host at the production path.
f'exec {shlex.quote(sys.executable)} -c '
"'import sys, lxc_apps; exec(sys.stdin.read())'\n")
def write(self, name, content):
path = self.bin / name
path.write_text(content)
path.chmod(0o755)
def shell(self, command, status=8, body=''):
return subprocess.run(['/bin/sh', '-c', command], text=True, capture_output=True,
env={**self.env, 'FETCH_STATUS': str(status), 'FETCH_BODY': body}, timeout=10)
def test_reproduces_original_false_success(self):
self.assertEqual(self.shell(LEGACY).returncode, 0)
fixed = self.shell(lxc_apps.protect_download_update_command(LEGACY))
self.assertNotEqual(fixed.returncode, 0)
self.assertIn('download failed', fixed.stderr)
def test_supported_literal_launchers_preserve_url_shell_and_environment(self):
for tool in ('wget -qLO -', 'wget -qO-', 'wget -qO -', 'curl -fsSL', 'curl -fSL'):
for shell in ('bash', 'sh', '/bin/bash', '/bin/sh'):
for prefix in ('', 'PHS_SILENT=0 ', 'PHS_SILENT=1 '):
original = f'''{prefix}{shell} -c "$({tool} '{URL}')"'''
with self.subTest(command=original):
prepared = lxc_apps.protect_download_update_command(original)
self.assertNotEqual(prepared, original)
self.assertEqual(lxc_apps.protect_download_update_command(prepared), prepared)
self.assertIn(URL, prepared)
result = self.shell(prepared, 0, 'echo "RAN:${PHS_SILENT:-unset}"')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), 'RAN:' + (prefix.strip()[-1] if prefix else 'unset'))
def test_does_not_reinterpret_unrelated_or_dynamic_commands(self):
for command in ('/opt/odoo/update.sh', 'false; true', 'echo "$(date)"',
'bash -c "$(wget -qLO - \'$UPDATE_URL\')"',
'bash -c "$(curl -fsSL https://example.com/update.sh; echo injected)"',
'bash -c "$(curl -fsSL https://example.com/update.sh?x=1&y=2)"',
'PHS_SILENT=1 bash -c "$(wget -qLO - https://example.com/update.sh)"; true',
'curl -fsSL https://example.com/update.sh | bash'):
self.assertEqual(lxc_apps.protect_download_update_command(command), command)
def test_failed_partial_and_empty_downloads_never_execute(self):
prepared = lxc_apps.protect_download_update_command(LEGACY)
for status, body in ((8, ''), (8, 'echo BAD_EXECUTION'), (0, '')):
result = self.shell(prepared, status, body)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn('BAD_EXECUTION', result.stdout)
result = self.shell(prepared, 0, 'echo REAL_UPDATER; exit 23')
self.assertEqual(result.returncode, 23)
self.assertIn('REAL_UPDATER', result.stdout)
def test_grouping_stops_later_apps_and_preserves_failures(self):
command = lxc_apps.protect_download_update_command(LEGACY) + ' && echo NEXT_APP'
self.assertNotIn('NEXT_APP', self.shell(command).stdout)
self.assertNotIn('NEXT_APP', self.shell(command, 0, 'exit 23').stdout)
self.assertIn('NEXT_APP', self.shell(command, 0, 'exit 0').stdout)
def test_real_runner_reports_failure_for_both_methods(self):
for method in ('helper', 'custom'):
for status, body, expected in ((8, '', 4), (8, 'echo BAD_EXECUTION', 4),
(0, '', 4), (0, 'exit 23', 4), (0, 'exit 0', 0)):
with self.subTest(method=method, status=status, body=body):
result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')],
capture_output=True, text=True, timeout=15, env={**self.env,
'RUN_HELPER': '1' if method == 'helper' else '0',
'UPDATE_COMMAND': LEGACY if method == 'custom' else '',
'FETCH_STATUS': str(status), 'FETCH_BODY': body})
self.assertEqual(result.returncode, expected, result.stdout + result.stderr)
self.assertEqual('=== Update complete' in result.stdout, expected == 0)
self.assertEqual('=== Update FAILED' in result.stdout, expected != 0)
self.assertNotIn('BAD_EXECUTION', result.stdout)
self.assert_terminal_notification(result.returncode)
def assert_terminal_notification(self, exit_code):
# Actual completion hook + finalizer; only IO/metadata are stubbed.
names = {'_terminal_lxc_update_completed', '_finalize_lxc_update'}
nodes = [n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
if isinstance(n, ast.FunctionDef) and n.name in names]
notification = Mock()
ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification,
_LXC_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'),
_normalise_lxc_update_run_id=lambda value, **_: value,
_normalise_lxc_update_targets=lambda values, _: values,
_normalise_lxc_update_labels=lambda values: values,
_json_list=lambda _: [], _lxc_update_finalizations={},
_lxc_update_finalization_lock=threading.Lock(), _LXC_UPDATE_FINALIZATION_TTL=3600,
_fast_guest_status=lambda *_: 'stopped',
_lxc_update_snapshot=lambda *_: {'ct_name': 'fixture'},
_lxc_update_target_labels=lambda *_: ['Odoo'],
_lxc_update_details=lambda **kwargs: kwargs['status'],
get_proxmox_node_name=lambda: 'fixture-node')
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'completion', 'exec'), ns)
params = {'RUN_ID': 'fixture-run', 'VMID': '101', 'TARGET': 'app'}
for _ in range(2):
ns['_terminal_lxc_update_completed'](script_path=ns['_LXC_APPLY_UPDATES_SCRIPT'],
params=params, exit_code=exit_code, duration_seconds=1)
notification.emit_event.assert_called_once()
event = notification.emit_event.call_args.kwargs
self.assertEqual(event['data']['result'], 'succeeded' if exit_code == 0 else 'failed')
self.assertEqual(event['severity'], 'INFO' if exit_code == 0 else 'WARNING')
if __name__ == '__main__':
unittest.main()
+565
View File
@@ -0,0 +1,565 @@
import sys
import subprocess
import tempfile
import unittest
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT / "AppImage" / "scripts"))
import lxc_apps # noqa: E402
import managed_installs # noqa: E402
class UpdateStrategyValidationTests(unittest.TestCase):
def test_custom_command_defaults_to_override(self):
ok, config = lxc_apps.validate_config({
"name": "Jellyfin",
"update_command": "systemctl restart jellyfin",
})
self.assertTrue(ok, config)
self.assertEqual(config["update_strategy"], "custom_override")
def test_legacy_helper_then_custom_is_normalised_to_override(self):
ok, config = lxc_apps.validate_config({
"name": "Jellyfin",
"update_command": "systemctl restart jellyfin",
"update_strategy": "helper_then_custom",
})
self.assertTrue(ok, config)
self.assertEqual(config["update_strategy"], "custom_override")
def test_unknown_strategy_is_ignored_and_normalised(self):
ok, config = lxc_apps.validate_config({
"name": "Jellyfin",
"update_command": "true",
"update_strategy": "run-everything",
})
self.assertTrue(ok, config)
self.assertEqual(config["update_strategy"], "custom_override")
class HelperEvidenceTests(unittest.TestCase):
def test_legacy_literal_wrapper_slug_is_supported(self):
wrapper = (
'bash -c "$(curl -fsSL '
'https://raw.githubusercontent.com/community-scripts/ProxmoxVE/'
'main/ct/jellyfin.sh)"'
)
self.assertEqual(
managed_installs._extract_helper_slug_from_update_wrapper(wrapper),
"jellyfin",
)
def test_current_generated_wrapper_slug_is_supported(self):
wrapper = """#!/usr/bin/env bash
# Community-Scripts update entrypoint (generated - do not edit by hand).
# Regenerated on install and on every successful update.
export SCRIPT_SLUG="nginxproxymanager"
export UPDATE_SCRIPT_NAME="nginxproxymanager"
export COMMUNITY_SCRIPTS_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
bash -c "$(curl -fsSL "${COMMUNITY_SCRIPTS_URL}/ct/${UPDATE_SCRIPT_NAME}.sh")"
"""
self.assertEqual(
managed_installs._extract_helper_slug_from_update_wrapper(wrapper),
"nginxproxymanager",
)
def test_update_script_name_is_a_compatible_fallback(self):
wrapper = "export UPDATE_SCRIPT_NAME='qbittorrent'"
self.assertEqual(
managed_installs._extract_helper_slug_from_update_wrapper(wrapper),
"qbittorrent",
)
def test_dynamic_or_command_chained_assignments_are_rejected(self):
for wrapper in (
'export SCRIPT_SLUG="$(touch /tmp/unsafe)"',
'export SCRIPT_SLUG="jellyfin"; touch /tmp/unsafe',
'export UPDATE_SCRIPT_NAME="${UNTRUSTED}"',
):
with self.subTest(wrapper=wrapper):
self.assertIsNone(
managed_installs._extract_helper_slug_from_update_wrapper(wrapper)
)
def test_wrapper_is_executable_evidence(self):
with patch.object(managed_installs, "_probe_helper_scripts_slug", return_value="jellyfin"):
slug, source = managed_installs._identify_helper_slug("101", "media")
self.assertEqual((slug, source), ("jellyfin", "update_wrapper"))
def test_tag_hostname_is_suggestion_only(self):
with patch.object(managed_installs, "_probe_helper_scripts_slug", return_value=None), \
patch.object(managed_installs, "_probe_lxc_tags", return_value={"community-scripts"}), \
patch.object(managed_installs, "_guess_helper_slug_from_hostname", return_value="jellyfin"):
slug, source = managed_installs._identify_helper_slug("101", "jellyfin")
self.assertEqual((slug, source), ("jellyfin", "tag_hostname"))
def _detect_one(self, source, slug="jellyfin"):
patches = (
patch.object(managed_installs, "_lxc_updates_detection_enabled", return_value=True),
patch.object(managed_installs, "_read_registry", return_value={"items": []}),
patch.object(managed_installs, "_list_pve_lxcs", return_value=[{
"vmid": "101", "status": "running", "name": "jellyfin",
}]),
patch.object(managed_installs, "_get_oci_managed_vmids", return_value={}),
patch.object(managed_installs, "_probe_lxc_is_oci", return_value=False),
patch.object(managed_installs, "_probe_lxc_os", return_value="debian"),
patch.object(managed_installs, "_identify_helper_slug", return_value=(slug, source)),
patch.object(managed_installs, "_fetch_helpers_cache", return_value={
slug: {"name": "Jellyfin", "updateable": True},
}),
)
for ctx in patches:
ctx.start()
try:
return managed_installs._detect_lxc_containers()[0]
finally:
for ctx in reversed(patches):
ctx.stop()
def test_hostname_guess_never_enables_updater(self):
item = self._detect_one("tag_hostname")
self.assertFalse(item["_has_app_updater"])
self.assertEqual(item["_helper_slug_source"], "tag_hostname")
def test_valid_wrapper_enables_updateable_app(self):
item = self._detect_one("update_wrapper")
self.assertTrue(item["_has_app_updater"])
def test_base_os_wrapper_never_enables_app_updater(self):
item = self._detect_one("update_wrapper", slug="debian")
self.assertFalse(item["_has_app_updater"])
class BulkUpdateConfigTests(unittest.TestCase):
def test_os_is_mandatory_and_a_second_target_is_required(self):
ok, error = lxc_apps.validate_bulk_update({"targets": ["app:abc"]})
self.assertFalse(ok)
self.assertIn("OS", error)
ok, error = lxc_apps.validate_bulk_update({"targets": ["os"]})
self.assertFalse(ok)
self.assertIn("application", error)
def test_targets_are_deduplicated_and_normalised(self):
ok, config = lxc_apps.validate_bulk_update({
"targets": ["docker-engine", "os", "app:abc", "docker-engine"],
})
self.assertTrue(ok, config)
self.assertEqual(config["targets"], ["os", "app:abc", "docker-engine"])
def test_only_opaque_docker_units_are_allowed(self):
ok, _ = lxc_apps.validate_bulk_update({
"targets": ["os", "docker-compose:media"],
})
self.assertFalse(ok)
ok, config = lxc_apps.validate_bulk_update({
"targets": ["os", "docker-unit:0123456789abcdefabcd"],
})
self.assertTrue(ok, config)
def test_bulk_config_round_trips_separately_from_schedule(self):
with tempfile.TemporaryDirectory() as temp_dir, \
patch.object(lxc_apps, "_APPS_DIR", temp_dir):
ok, _ = lxc_apps.update_schedule(101, {
"enabled": False,
"cron": "",
"target": "os",
"targets": ["os"],
})
self.assertTrue(ok)
ok, _ = lxc_apps.update_bulk_update(101, {
"targets": ["os", "docker-engine"],
})
self.assertTrue(ok)
self.assertEqual(lxc_apps.get_bulk_update(101)["targets"], ["os", "docker-engine"])
self.assertEqual(lxc_apps.get_schedule(101)["targets"], ["os"])
self.assertTrue(lxc_apps.delete_bulk_update(101))
self.assertIsNone(lxc_apps.get_bulk_update(101))
self.assertIsNotNone(lxc_apps.get_schedule(101))
class ScheduledReleaseTargetTests(unittest.TestCase):
def test_untracked_custom_app_is_not_release_gated(self):
gated, remaining = lxc_apps.partition_scheduled_release_targets(
["app:links"],
[{
"id": "links",
"name": "Links only",
"update_command": "systemctl restart links",
}],
)
self.assertEqual(gated, set())
self.assertEqual(remaining, ["app:links"])
def test_deferred_tracked_app_does_not_block_untracked_or_os(self):
gated, remaining = lxc_apps.partition_scheduled_release_targets(
["os", "app:tracked", "app:links"],
[
{"id": "tracked", "installed_via": "binary"},
{"id": "links", "update_command": "true"},
],
)
self.assertEqual(gated, {"tracked"})
self.assertEqual(remaining, ["os", "app:links"])
def test_legacy_apps_target_keeps_only_untracked_when_gate_defers(self):
gated, remaining = lxc_apps.partition_scheduled_release_targets(
["os", "apps"],
[
{"id": "tracked", "installed_via": "file"},
{"id": "links", "update_command": "true"},
{"id": "docker", "installed_via": "binary", "helper_slug": "docker"},
],
)
self.assertEqual(gated, {"tracked"})
self.assertEqual(remaining, ["os", "app:links"])
class AppCacheWriteThroughContractTests(unittest.TestCase):
def test_successful_mutations_publish_the_complete_sidecar(self):
cache_source = (REPO_ROOT / "AppImage" / "lib" / "lxc-apps-cache.ts").read_text()
panel_source = (REPO_ROOT / "AppImage" / "components" / "lxc-app-panel.tsx").read_text()
server_source = (REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text()
self.assertIn("export function setLxcAppsCached", cache_source)
self.assertGreaterEqual(panel_source.count("setLxcAppsCached(vmid, r, suggestions)"), 6)
self.assertNotIn("_vm_cache_put(_vm_apps_cache", server_source)
self.assertIn("lxc_apps.load_sidecar(vmid)", server_source)
def test_post_apply_revalidates_without_evicting_render_seed(self):
source = (REPO_ROOT / "AppImage" / "components" / "virtual-machines.tsx").read_text()
apply_block = source[source.index("const handleApplyComplete"):source.index("const getAggregateUpdateCheck")]
self.assertIn("void fetchLxcApps(applyVmid)", apply_block)
self.assertNotIn("invalidateLxcApps(applyVmid)", apply_block)
def test_apply_completion_is_owned_by_the_backend_and_idempotent(self):
server_source = (REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text()
terminal_source = (REPO_ROOT / "AppImage" / "scripts" / "flask_terminal_routes.py").read_text()
endpoint_block = server_source[
server_source.index("def api_lxc_updates_applied"):
server_source.index("@app.route('/api/health/thresholds'")
]
finalizer_block = server_source[
server_source.index("def _finalize_lxc_update"):
server_source.index("def _terminal_lxc_update_completed")
]
self.assertIn("set_script_completion_hook(_terminal_lxc_update_completed)", server_source)
self.assertIn("params.get('RUN_ID')", server_source)
self.assertIn("_run_script_completion_hook", terminal_source)
self.assertIn("_lxc_update_finalizations", finalizer_block)
self.assertIn("managed_installs.refresh_lxc(vmid)", finalizer_block)
self.assertNotIn("managed_installs.check_for_updates(force=True)", endpoint_block)
self.assertIn("entity_id=f'{vmid}:{safe_run_id}'", finalizer_block)
def test_scheduled_updates_use_the_shared_finalizer(self):
source = (REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text()
scheduler_block = source[
source.index("def _run_scheduled_update"):
source.index("def _scheduler_loop")
]
self.assertIn("_finalize_lxc_update(", scheduler_block)
self.assertIn("return finish('partial'", scheduler_block)
self.assertIn("before_snapshot=before", scheduler_block)
class DockerStackNotificationTests(unittest.TestCase):
def _docker_app(self):
return {
"id": "docker",
"name": "Docker",
"helper_slug": "docker",
"notifications_enabled": True,
"state": {
"installed_version": "27.4.0",
"latest_version": "29.7.2",
"update_available": True,
},
}
def _inventory(self, reference="portainer/portainer-ce:latest", digest="sha256:new"):
return {
"available": True,
"images": [{
"reference": reference,
"installed_version": "2.19.4",
"available_version": "2.39.6",
"remote_digest": digest,
"update_available": True,
}],
}
def test_engine_and_images_share_one_payload(self):
payload = lxc_apps._docker_stack_notification_payload(
101, self._docker_app(), self._inventory(), "docker",
)
self.assertEqual(payload["count"], 2)
self.assertIn("Docker Engine: 27.4.0 → 29.7.2", payload["details"])
self.assertIn("portainer/portainer-ce:latest: 2.19.4 → 2.39.6", payload["details"])
def test_signature_changes_when_pending_identity_changes_at_same_count(self):
first = lxc_apps._docker_stack_notification_payload(
101, {**self._docker_app(), "state": {}}, self._inventory(), "docker",
)
second = lxc_apps._docker_stack_notification_payload(
101,
{**self._docker_app(), "state": {}},
self._inventory("library/nginx:latest", "sha256:other"),
"docker",
)
self.assertEqual(first["count"], second["count"])
self.assertNotEqual(first["signature"], second["signature"])
def test_generic_app_event_does_not_duplicate_docker_stack_event(self):
emitter = MagicMock()
fake_module = SimpleNamespace(notification_manager=emitter)
with patch.dict(sys.modules, {"notification_manager": fake_module}):
lxc_apps._fire_update_notification(101, self._docker_app())
emitter.emit_event.assert_not_called()
class DockerInventoryCachePolicyTests(unittest.TestCase):
def setUp(self):
self.original_cache = lxc_apps._docker_inventory_cache
lxc_apps._docker_inventory_cache = {}
def tearDown(self):
lxc_apps._docker_inventory_cache = self.original_cache
def _inventory(self, available=True):
return {
"vmid": 101,
"available": available,
"images": [],
"checked_at_unix": 1_000_000,
}
def test_available_inventory_uses_24_hour_cache(self):
self.assertEqual(lxc_apps._DOCKER_INVENTORY_TTL_SEC, 24 * 3600)
lxc_apps._docker_inventory_cache["101"] = self._inventory()
fresh_scan = self._inventory()
fresh_scan["engine_version"] = "new-scan"
with patch.object(lxc_apps.time, "time", return_value=1_000_000 + 3600), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=fresh_scan) as scan:
cached = lxc_apps.get_docker_inventory(101)
self.assertNotIn("engine_version", cached)
scan.assert_not_called()
def test_available_inventory_refreshes_after_24_hours(self):
lxc_apps._docker_inventory_cache["101"] = self._inventory()
fresh_scan = self._inventory()
fresh_scan["engine_version"] = "new-scan"
with patch.object(lxc_apps.time, "time", return_value=1_000_000 + 24 * 3600 + 1), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=fresh_scan) as scan:
refreshed = lxc_apps.get_docker_inventory(101)
self.assertEqual(refreshed["engine_version"], "new-scan")
scan.assert_called_once_with(101)
def test_unavailable_inventory_retries_after_30_seconds(self):
lxc_apps._docker_inventory_cache["101"] = self._inventory(available=False)
fresh_scan = self._inventory(available=True)
with patch.object(lxc_apps.time, "time", return_value=1_000_029), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=fresh_scan) as scan:
lxc_apps.get_docker_inventory(101)
scan.assert_not_called()
with patch.object(lxc_apps.time, "time", return_value=1_000_031), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=fresh_scan) as scan:
lxc_apps.get_docker_inventory(101)
scan.assert_called_once_with(101)
def test_force_failure_preserves_docker_unit_identity_as_refreshing(self):
previous = self._inventory(available=True)
previous.update({
"images": [{"reference": "demo/app:latest", "update_available": True}],
"update_units": [{
"id": "docker-unit:0123456789abcdefabcd",
"display_name": "Demo",
"update_available": True,
}],
})
lxc_apps._docker_inventory_cache["101"] = previous
failed_scan = {
"vmid": 101,
"available": False,
"images": [],
"update_count": 0,
"error": "Docker is not ready",
}
with patch.object(lxc_apps.time, "time", return_value=1_000_100), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=failed_scan):
result = lxc_apps.get_docker_inventory(101, force=True)
self.assertFalse(result["available"])
self.assertTrue(result["refreshing"])
self.assertEqual(result["update_units"][0]["display_name"], "Demo")
self.assertEqual(result["update_units"][0]["id"], "docker-unit:0123456789abcdefabcd")
def test_force_failure_preserves_empty_lifecycle_pending_state(self):
pending = self._inventory(available=False)
pending.update({
"refreshing": True,
"images": [],
"update_units": [],
"error": None,
})
lxc_apps._docker_inventory_cache["101"] = pending
failed_scan = {
"vmid": 101,
"available": False,
"images": [],
"update_units": [],
"update_count": 0,
"error": "Docker is not ready",
}
with patch.object(lxc_apps.time, "time", return_value=1_000_100), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=failed_scan):
result = lxc_apps.get_docker_inventory(101, force=True)
self.assertFalse(result["available"])
self.assertTrue(result["refreshing"])
self.assertEqual(result["images"], [])
self.assertEqual(result["update_units"], [])
def test_lifecycle_transition_keeps_ids_but_clears_old_update_state(self):
previous = self._inventory(available=True)
previous.update({
"images": [{"reference": "demo/app:latest", "update_available": True}],
"update_units": [{
"id": "docker-unit:0123456789abcdefabcd",
"display_name": "Demo",
"update_available": True,
}],
})
lxc_apps._docker_inventory_cache["101"] = previous
pending = lxc_apps.mark_docker_inventory_refreshing(101)
self.assertFalse(pending["available"])
self.assertTrue(pending["refreshing"])
self.assertIsNone(pending["images"][0]["update_available"])
self.assertIsNone(pending["update_units"][0]["update_available"])
self.assertTrue(previous["images"][0]["update_available"])
def test_docker_ps_timeout_is_not_reported_as_an_empty_inventory(self):
with patch.object(lxc_apps, "_pct_exec", side_effect=[
(0, "27.4.0\n", ""),
(124, "", "timed out after 10s"),
]):
result = lxc_apps._docker_inventory_from_ct(101)
self.assertFalse(result["available"])
self.assertEqual(result["images"], [])
self.assertIn("not ready", result["error"])
def test_docker_image_ls_timeout_is_not_reported_as_empty(self):
with patch.object(lxc_apps, "_pct_exec", side_effect=[
(0, "27.4.0\n", ""),
(0, "", ""),
(124, "", "timed out after 15s"),
]):
result = lxc_apps._docker_inventory_from_ct(101)
self.assertFalse(result["available"])
self.assertEqual(result["images"], [])
self.assertIn("timed out", result["error"])
def test_pct_exec_timeout_kills_the_complete_local_process_group(self):
process = MagicMock()
process.pid = 4321
process.communicate.side_effect = [
subprocess.TimeoutExpired(cmd="pct", timeout=1),
("", ""),
]
with patch.object(lxc_apps.subprocess, "Popen", return_value=process), \
patch.object(lxc_apps.os, "killpg") as killpg:
rc, out, err = lxc_apps._pct_exec(101, ["docker", "version"], timeout=1)
self.assertEqual((rc, out), (124, ""))
self.assertIn("timed out", err)
killpg.assert_called_once_with(4321, lxc_apps.signal.SIGKILL)
def test_inventory_does_not_define_disk_persistence(self):
source = (REPO_ROOT / "AppImage" / "scripts" / "lxc_apps.py").read_text()
self.assertNotIn("docker_inventory.json", source)
self.assertNotIn("_save_docker_inventory_disk", source)
def test_daily_collector_and_ui_force_points_are_explicit(self):
notification_source = (REPO_ROOT / "AppImage" / "scripts" / "notification_events.py").read_text()
frontend_source = (REPO_ROOT / "AppImage" / "components" / "virtual-machines.tsx").read_text()
automatic_block = frontend_source[
frontend_source.index("// Docker drift is opt-in"):
frontend_source.index("const refreshDockerInventory")
]
manual_block = frontend_source[
frontend_source.index("const refreshDockerInventory"):
frontend_source.index("const closeCustomCmdEditor")
]
self.assertIn("refresh_docker_inventories(force=True)", notification_source)
self.assertIn("/docker/inventory`", automatic_block)
self.assertNotIn("?force=1", automatic_block)
self.assertIn("/docker/inventory?force=1", manual_block)
def test_startup_and_lifecycle_rebuild_memory_inventory_without_blank_gap(self):
server_source = (REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text()
lifecycle_block = server_source[
server_source.index("def _refresh_started_guest"):
server_source.index("def _schedule_started_guest_refresh")
]
startup_block = server_source[
server_source.index("def _deferred_startup_inits"):
server_source.index("threading.Thread(target=_deferred_startup_inits")
]
self.assertIn("refresh_docker_inventories(force=True)", startup_block)
self.assertIn("mark_docker_inventory_refreshing(vmid)", lifecycle_block)
self.assertIn("get_docker_inventory(vmid, force=True)", lifecycle_block)
self.assertIn("docker_refresh_pending", lifecycle_block)
self.assertIn("time.monotonic() + 7 * 60", lifecycle_block)
self.assertIn("Docker ready in", lifecycle_block)
self.assertLess(
lifecycle_block.index("get_docker_inventory(vmid, force=True)"),
lifecycle_block.index("get_suggestions(vmid, force=True)"),
)
self.assertLess(
lifecycle_block.index("_publish_guest_modal_cache_revision(vmid)"),
lifecycle_block.index("get_suggestions(vmid, force=True)"),
)
def test_manual_docker_refresh_publishes_endpoint_result_without_full_vm_wait(self):
frontend_source = (REPO_ROOT / "AppImage" / "components" / "virtual-machines.tsx").read_text()
manual_block = frontend_source[
frontend_source.index("const refreshDockerInventory"):
frontend_source.index("const closeCustomCmdEditor")
]
self.assertIn("const inventory = await fetchApi<LxcDockerInventory>", manual_block)
self.assertIn("docker_inventory: inventory", manual_block)
self.assertIn("{ revalidate: false }", manual_block)
self.assertNotIn("await mutate()", manual_block)
def test_bulk_ui_does_not_expose_internal_docker_ids_while_refreshing(self):
frontend_source = (REPO_ROOT / "AppImage" / "components" / "virtual-machines.tsx").read_text()
bulk_block = frontend_source[
frontend_source.index("const pendingDockerBulkTargets"):
frontend_source.index("{/* Options card")
]
self.assertIn("vmLxc.bulkUpdate.dockerInventoryPending", bulk_block)
self.assertIn("vmLxc.bulkUpdate.missingDockerTarget", bulk_block)
self.assertIn("pendingDockerBulkTargets.length > 0", bulk_block)
self.assertNotIn("{target}\n", bulk_block)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,181 @@
"""Updater consent regressions: temporary sidecars, no guests or network."""
import ast
import copy
from datetime import datetime
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import time
import types
import unittest
import uuid
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import lxc_apps as apps
def routes():
names = {'_scheduled_helper_enabled', '_resolve_bulk_update_plan',
'_compose_scheduled_update_command', '_run_scheduled_update',
'_normalise_schedule_targets'}
nodes = [node for node in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
if isinstance(node, ast.FunctionDef) and node.name in names]
ns = dict(json=json, os=os, re=re, time=time, uuid=uuid, datetime=datetime,
subprocess=types.SimpleNamespace(run=Mock(return_value=types.SimpleNamespace(returncode=0)),
TimeoutExpired=subprocess.TimeoutExpired),
_DOCKER_ENGINE_INTEGRATED_COMMAND='integrated-docker',
_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'),
_create_lxc_update_log=lambda *_: ('test.log', None),
_fast_guest_status=lambda *_: 'running',
_lxc_update_snapshot=lambda *_: {'ct_name': 'test'},
_lxc_update_target_labels=lambda *_: [],
_append_lxc_update_log=lambda *_: None,
_prune_lxc_update_logs=lambda *_: None,
_finalize_lxc_update=Mock(return_value={}),
_inspect_lxc_reboot_requirement=lambda *_, **__: (False, [], None))
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'update-routes', 'exec'), ns)
return ns
class UpdateChoiceTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.addCleanup(patch.stopall)
patch.object(apps, '_APPS_DIR', directory.name).start()
patch.object(apps, 'check_app', return_value=None).start()
self.item = {'type': 'lxc', '_vmid': 101, '_has_app_updater': True,
'_helper_slug_source': 'update_wrapper', '_helper_slug': 'qbittorrent'}
patch.dict(sys.modules, {'lxc_apps': apps, 'managed_installs': types.SimpleNamespace(
get_active_items=lambda: [self.item])}).start()
self.api = routes()
def save(self, records, **extra):
data = {'vmid': 101, 'apps': copy.deepcopy(records), **extra}
self.assertTrue(apps._write_sidecar(101, data))
return apps._read_sidecar(101)
def helper(self, method='helper', **extra):
return {'id': 'qbit', 'name': 'qBittorrent', 'helper_slug': 'qbittorrent',
'update_method': method, **extra}
def test_registration_does_not_enable_helper_even_in_legacy_wildcard_schedule(self):
self.save([], schedule={'enabled': True, 'target': 'both'})
ok, sidecar = apps.add_app(101, {'name': 'qBittorrent', 'helper_slug': 'qbittorrent'})
self.assertTrue(ok, sidecar)
self.assertEqual(sidecar['apps'][0]['update_method'], 'none')
self.assertFalse(self.api['_scheduled_helper_enabled'](101, 'app', ['apps']))
def test_legacy_commands_and_explicit_selections_survive_migration(self):
helper = self.helper()
helper.pop('update_method')
result = self.save([helper, {'id': 'manual', 'update_command': 'my-updater'}],
bulk_update={'targets': ['os', 'app:qbit']})
self.assertEqual([a['update_method'] for a in result['apps']], ['helper', 'custom'])
result = self.save([helper], schedule={'enabled': True, 'target': 'both'})
self.assertEqual(result['apps'][0]['update_method'], 'helper')
result = self.save([helper], schedule={'enabled': False, 'target': 'both'})
self.assertEqual(result['apps'][0]['update_method'], 'none')
self.assertEqual(self.save([helper])['apps'][0]['update_method'], 'none')
def test_choice_survives_editing_ports_and_disable_never_falls_back(self):
self.save([self.helper()], schedule={'enabled': True, 'targets': ['apps']})
record = apps._read_sidecar(101)['apps'][0]
ok, result = apps.update_app(101, 'qbit', {**record, 'ports': [{'port': 8090}]})
self.assertTrue(ok, result)
self.assertEqual(result['apps'][0]['update_method'], 'helper')
ok, result = apps.update_app(101, 'qbit', {**record, 'update_method': 'custom', 'update_command': 'my-updater'})
self.assertTrue(ok, result)
self.assertFalse(self.api['_scheduled_helper_enabled'](101, 'app', ['apps']))
ok, result = apps.update_app(101, 'qbit', {**record, 'update_method': 'none', 'update_command': ''})
self.assertTrue(ok, result)
self.assertFalse(apps.helper_update_selected(101, 'qbittorrent'))
self.assertTrue(result['schedule']['enabled'])
def test_conflicting_or_empty_methods_are_rejected(self):
for payload in ({'update_method': 'custom'}, {'update_method': 'helper'},
{'update_method': 'helper', 'helper_slug': 'qbittorrent', 'update_command': 'true'},
{'update_method': 'none', 'update_command': 'true'}, {'update_method': 'automatic'}):
self.assertFalse(apps.validate_config({'name': 'test', **payload})[0], payload)
def test_multi_app_bulk_keeps_methods_separate(self):
self.save([self.helper(), {'id': 'other', 'name': 'Other', 'update_method': 'custom',
'update_command': '/opt/other/update.sh'},
{'id': 'unconfigured', 'helper_slug': 'jellyfin', 'update_method': 'none'}])
plan = self.api['_resolve_bulk_update_plan'](101, ['os', 'app:qbit', 'app:other'])
self.assertTrue(plan['ok'], plan)
self.assertTrue(plan['run_helper'])
self.assertTrue(plan['allow_helper_with_custom'])
self.assertEqual(plan['update_command'], '/opt/other/update.sh')
plan = self.api['_resolve_bulk_update_plan'](101, ['os', 'app:unconfigured'])
self.assertFalse(plan['ok'])
self.assertFalse(apps.helper_update_selected(101, 'qbittorrent', ['app:other']))
def test_helper_for_one_app_never_authorizes_a_different_helper(self):
self.save([self.helper(), {'id': 'wrong', 'helper_slug': 'jellyfin', 'update_method': 'helper'}])
plan = self.api['_resolve_bulk_update_plan'](101, ['os', 'app:qbit', 'app:wrong'])
self.assertFalse(plan['ok'])
self.assertEqual(plan['unavailable'][0]['target'], 'app:wrong')
def test_legacy_download_guard_covers_bulk_and_schedule_without_rewriting_settings(self):
command = 'PHS_SILENT=1 bash -c "$(wget -qLO - \'https://example.com/odoo.sh2\')"'
self.save([{'id': 'odoo', 'name': 'Odoo', 'update_method': 'custom', 'update_command': command},
{'id': 'other', 'name': 'Other', 'update_method': 'custom', 'update_command': 'echo OTHER'}])
saved_before = Path(apps._sidecar_path(101)).read_bytes()
plan = self.api['_resolve_bulk_update_plan'](101, ['os', 'app:odoo', 'app:other'])
self.assertTrue(plan['ok'])
self.assertIn('updater download failed', plan['update_command'])
self.assertTrue(plan['update_command'].endswith(') && echo OTHER'))
self.api['subprocess'].run.return_value.returncode = 4
result = self.api['_run_scheduled_update'](101, {'targets': ['app:odoo', 'app:other']})
self.assertEqual(result['status'], 'failure')
actual = self.api['subprocess'].run.call_args.kwargs['env']['UPDATE_COMMAND']
self.assertEqual(actual, plan['update_command'])
self.assertEqual(self.api['_finalize_lxc_update'].call_args.kwargs['status'], 'failure')
self.assertEqual(Path(apps._sidecar_path(101)).read_bytes(), saved_before)
self.assertEqual(apps._read_sidecar(101)['apps'][0]['update_command'], command)
def test_detection_and_duplicate_conflicts_do_not_authorize_execution(self):
self.save([self.helper('none')])
self.assertFalse(apps.helper_update_selected(101, 'qbittorrent'))
self.save([self.helper(), {**self.helper('custom'), 'id': 'duplicate', 'update_command': 'my-update'}])
self.assertFalse(apps.helper_update_selected(101, 'qbittorrent'))
self.save([self.helper()])
self.item['_helper_slug_source'] = 'tag_hostname'
self.assertFalse(self.api['_scheduled_helper_enabled'](101, 'app', ['apps']))
def test_disabled_app_schedule_reports_skipped_without_running(self):
self.save([self.helper('none')])
result = self.api['_run_scheduled_update'](101, {'targets': ['app:qbit']})
self.assertEqual(result['status'], 'skipped')
self.assertEqual(result['executed_targets'], [])
self.api['subprocess'].run.assert_not_called()
def test_disabled_app_does_not_block_os_or_custom_app_and_reports_partial(self):
self.save([self.helper('none'), {'id': 'other', 'update_command': 'my-updater'}])
result = self.api['_run_scheduled_update'](101, {'targets': ['os', 'app:qbit', 'app:other']})
self.assertEqual(result['status'], 'partial')
self.assertEqual(result['executed_targets'], ['os', 'app:other'])
env = self.api['subprocess'].run.call_args.kwargs['env']
self.assertEqual(env['RUN_HELPER'], '0')
self.assertEqual(env['UPDATE_COMMAND'], 'my-updater')
def test_wildcard_only_runs_selected_methods(self):
self.save([self.helper(), {'id': 'other', 'update_command': 'my-updater'},
{'id': 'links', 'name': 'Links only', 'update_method': 'none'}])
result = self.api['_run_scheduled_update'](101, {'targets': ['apps']})
self.assertEqual(result['status'], 'success')
self.assertEqual(result['executed_targets'], ['app:qbit', 'app:other'])
env = self.api['subprocess'].run.call_args.kwargs['env']
self.assertEqual(env['RUN_HELPER'], '1')
self.assertEqual(env['UPDATE_COMMAND'], 'my-updater')
if __name__ == '__main__':
unittest.main()
+280
View File
@@ -0,0 +1,280 @@
// Exercise the actual selector with a tiny JSX/hook harness; no browser or API.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const {spawnSync} = require('node:child_process')
const os = require('node:os')
const root = path.resolve(__dirname, '../../AppImage')
const ts = require(path.join(root, 'node_modules/typescript'))
const source = fs.readFileSync(path.join(root, 'components/app-updater-editor.tsx'), 'utf8')
const result = ts.transpileModule(source, {compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
}, reportDiagnostics: true})
assert.equal(result.diagnostics.length, 0)
const flatten = node => !node || typeof node !== 'object' ? [] : [node, ...(
[node.props?.children].flat(Infinity).flatMap(flatten)
)]
// Check the real shared Button and cn/tailwind-merge, not only editor props.
function loadSource(relative) {
const compiled = ts.transpileModule(fs.readFileSync(path.join(root, relative), 'utf8'), {
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX},
})
const context = {exports: {}, require: name => name === '@/lib/utils'
? loadSource('lib/utils.ts') : require(path.join(root, 'node_modules', name))}
vm.runInNewContext(compiled.outputText, context)
return context.exports
}
const actualButton = loadSource('components/ui/button.tsx').Button
const navbarSource = fs.readFileSync(path.join(root, 'components/proxmox-dashboard.tsx'), 'utf8')
const appPanelSource = fs.readFileSync(path.join(root, 'components/lxc-app-panel.tsx'), 'utf8')
function assertSaveContrast(save) {
const dom = actualButton.render(save.props, null)
assert.equal(dom.props.disabled, save.props.disabled)
assert.match(dom.props.className, /\btext-white\b/)
assert.match(dom.props.className, /disabled:opacity-50/)
assert(!dom.props.className.includes('disabled:opacity-100'), 'retain the shared disabled appearance')
assert(!dom.props.className.includes('disabled:bg-blue-800'), 'do not override the disabled background')
const background = dom.props.className.split(' ').find(token => /^bg-blue-\d+$/.test(token))
assert.equal(background, 'bg-blue-500')
assert(navbarSource.includes(`data-[state=active]:${background}`), 'match the active navigation tab blue')
}
let englishEditor
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
let help = null
const t = key => {
const value = key.split('.').reduce((object, part) => object?.[part], messages)
assert.equal(typeof value, 'string', `${locale}: missing ${key}`)
return value
}
const context = {exports: {}, require: name => {
if (name === 'react') return {useId: () => 'test-command', useState: () => [help, value => {help = value}]}
if (name === 'react/jsx-runtime') return {jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})}
if (name === '@/lib/i18n/provider') return {useT: () => t}
return new Proxy({}, {get: (_, key) => String(key)})
}}
vm.runInNewContext(result.outputText, context)
if (locale === 'en') englishEditor = context.exports.AppUpdaterEditor
let saves = 0, method = 'none', command = ''
const props = {method, command, helperAvailable: true, helperSlug: 'qbittorrent', configured: false,
saving: false, changed: true, onMethodChange: value => {method = value}, onCommandChange: value => {command = value},
onSave: () => {saves++}, onCancel: () => {}, onRemove: () => {}}
const render = overrides => flatten(context.exports.AppUpdaterEditor({...props, method, command, ...overrides}))
let tree = render()
if (locale === 'es') assert.equal(t('vmLxc.updates.updaterChoiceHint'), 'Elige y guarda un método de actualización.')
assert.equal(tree.filter(n => n.props['aria-pressed'] === true).length, 0)
assert.equal(tree.filter(n => n.type === 'Textarea').length, 0)
let save = tree.find(n => n.type === 'Button' && n.props.onClick === props.onSave)
assert.equal(save.props.disabled, true)
assert.match(save.props.className, /bg-blue-500/)
assert.match(save.props.className, /hover:bg-blue-600/)
assert.match(save.props.className, /text-white/)
assertSaveContrast(save)
const helperButton = tree.find(n => n.type === 'Button' && n.props.children === t('vmLxc.updates.helperMethod'))
helperButton.props.onClick()
assert.equal(method, 'helper')
assert.equal(saves, 0, 'selecting must not execute or save')
tree = render()
const helperField = tree.find(n => n.type === 'Textarea')
const canonicalHelper = helperField.props.value
assert.equal(canonicalHelper, 'PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/qbittorrent.sh)"')
assert(!canonicalHelper.includes('_proxmenux_updater'), 'internal guards must not appear in the editor')
assert(!helperField.props.readOnly, 'the helper launcher must be editable')
helperField.props.onChange({target: {value: canonicalHelper}})
assert.equal(method, 'helper', 'an unchanged launcher remains the official method')
const editedHelper = canonicalHelper.replace('PHS_SILENT=1', 'PHS_SILENT=0')
helperField.props.onChange({target: {value: editedHelper}})
assert.equal(method, 'custom', 'editing must route execution through the saved custom command')
assert.equal(command, editedHelper)
assert.equal(render().find(n => n.type === 'Textarea').props.value, editedHelper)
helperButton.props.onClick()
assert.equal(render().find(n => n.type === 'Textarea').props.value, canonicalHelper)
assert.equal(command, editedHelper, 'switching back to helper preserves the unsaved custom draft')
command = ''
tree = render()
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, false)
assertSaveContrast(tree.find(n => n.props.onClick === props.onSave))
for (const overrides of [{saving: true}, {changed: false}]) {
const disabledSave = render(overrides).find(n => n.props.onClick === props.onSave)
assert.equal(disabledSave.props.disabled, true)
assertSaveContrast(disabledSave)
}
const helperInfo = tree.find(n => n.props['aria-label'] === t('vmLxc.updates.helperMethodHelp'))
assert.match(helperInfo.props.className, /text-blue-500/)
helperInfo.props.onClick()
tree = render()
assert.equal(tree.find(n => n.type === 'Dialog').props.open, true)
assert(tree.some(n => n.type === 'a' && n.props.href.endsWith('/ct/qbittorrent.sh')))
assert(tree.some(n => n.type === 'a' && n.props.href === 'https://community-scripts.org/docs/tools/pve/update-apps'))
const helperLinks = tree.filter(n => n.type === 'a')
assert.equal(helperLinks.length, 2)
for (const link of helperLinks) {
assert.match(link.props.className, /text-blue-400 hover:text-blue-300/)
assert(appPanelSource.includes('text-blue-400 hover:text-blue-300'), 'use the same colors as the App web links')
}
assert(tree.some(n => n.type === 'code' && n.props.children === canonicalHelper))
assert(!render({helperSlug: "bad'; touch /tmp/injected"}).some(n => n.type === 'code'))
assert(!render({helperSlug: undefined}).some(n => n.type === 'code'))
assert.equal(tree.filter(n => n.type === 'Textarea').length, 1)
assert.equal(render({helperSlug: undefined}).find(n => n.props.onClick === props.onSave).props.disabled, true)
const customInfo = tree.find(n => n.props['aria-label'] === t('vmLxc.updates.customMethodHelp'))
assert.match(customInfo.props.className, /text-blue-500/)
customInfo.props.onClick()
tree = render()
const headingIndex = tree.findIndex(n => n.type === 'p' && n.props.children === t('vmLxc.updates.customExamplesHeading'))
const descriptionIndex = tree.findIndex(n => n.type === 'DialogDescription')
const firstExampleIndex = tree.findIndex(n => n.type === 'code')
assert(headingIndex > descriptionIndex && headingIndex < firstExampleIndex, 'examples heading follows the introduction')
if (locale === 'es') assert.equal(t('vmLxc.updates.customExamplesHeading'), 'Ejemplos para adaptar:')
const examples = tree.filter(n => n.type === 'code').map(n => n.props.children)
assert.equal(examples.length, 4)
assert(examples.includes('/opt/my-app/update.sh'))
assert(examples[1].includes("curl -fsSL 'https://example.com/my-app/update.sh'"))
assert(examples[1].includes('-o "$script" &&\nbash "$script"'), 'never execute a failed download')
assert(examples[1].includes('trap'), 'clean up the temporary download')
for (const example of examples) {
const syntax = spawnSync('sh', ['-n'], {input: example, encoding: 'utf8'})
assert.equal(syntax.status, 0, syntax.stderr)
}
if (locale === 'en') {
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'proxmenux-helper-launcher-'))
try {
// The visible command stays short; the actual backend adds protection.
fs.symlinkSync('/bin/bash', path.join(fixture, 'bash'))
const fetch = '#!/bin/sh\nprintf "%s" "$FETCH_BODY"\nexit "$FETCH_STATUS"\n'
fs.writeFileSync(path.join(fixture, 'curl'), fetch, {mode: 0o755})
for (const tool of ['wget', 'curl']) {
const wget = path.join(fixture, 'wget')
if (tool === 'wget') fs.writeFileSync(wget, fetch, {mode: 0o755})
else fs.unlinkSync(wget)
const command = canonicalHelper.replaceAll('qbittorrent.sh', 'qbittorrent.sh2')
.replace('curl -fsSL', tool === 'wget' ? 'wget -qLO -' : 'curl -fsSL')
const prepared = spawnSync('python3', ['-c',
'import sys; from lxc_apps import protect_download_update_command; sys.stdout.write(protect_download_update_command(sys.stdin.read()))'], {
input: command, encoding: 'utf8', env: {...process.env, PYTHONPATH: path.join(root, 'scripts')},
})
assert.equal(prepared.status, 0, prepared.stderr)
assert(prepared.stdout.includes('updater download failed'), 'both curl and historical wget launchers remain protected')
for (const [status, body, expected] of [[8, '', 1], [8, 'echo SHOULD_NOT_RUN', 1],
[0, '', 1], [0, 'echo SCRIPT_RAN; exit 0', 0], [0, 'echo SCRIPT_RAN; exit 23', 23]]) {
const run = spawnSync('/bin/sh', ['-c', prepared.stdout], {
encoding: 'utf8', env: {...process.env, PATH: fixture, FETCH_STATUS: String(status), FETCH_BODY: body},
})
assert.equal(run.status, expected, run.stderr)
assert(!run.stdout.includes('SHOULD_NOT_RUN'), 'a partial failed download must not execute')
assert.equal(run.stdout.includes('SCRIPT_RAN'), status === 0 && body !== '')
}
}
} finally {
fs.rmSync(fixture, {recursive: true, force: true})
}
}
if (locale === 'en') {
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'proxmenux-online-example-'))
try {
const bin = path.join(fixture, 'bin')
fs.mkdirSync(bin)
fs.writeFileSync(path.join(bin, 'curl'), '#!/bin/sh\nexit "$EXAMPLE_FETCH_STATUS"\n', {mode: 0o755})
fs.writeFileSync(path.join(bin, 'bash'), '#!/bin/sh\n: > "$EXAMPLE_EXECUTION_MARKER"\n', {mode: 0o755})
for (const status of [0, 22]) {
const marker = path.join(fixture, `ran-${status}`)
const run = spawnSync('/bin/sh', ['-c', examples[1]], {encoding: 'utf8', env: {
...process.env, PATH: `${bin}:${process.env.PATH}`, TMPDIR: fixture,
EXAMPLE_FETCH_STATUS: String(status), EXAMPLE_EXECUTION_MARKER: marker,
}})
assert.equal(run.status, status, run.stderr)
assert.equal(fs.existsSync(marker), status === 0, 'do not run a failed or incomplete download')
}
assert.deepEqual(fs.readdirSync(fixture).sort(), ['bin', 'ran-0'], 'temporary scripts must be removed')
} finally {
fs.rmSync(fixture, {recursive: true, force: true})
}
}
assert(examples.some(code => code.includes('apt-get install -y --only-upgrade my-package')))
assert(examples.some(code => code.includes('install -b -m 0755 /tmp/my-app.new')))
assert.equal(command, '', 'help examples must not change the saved command')
assert.equal(saves, 0, 'opening help must not execute or save')
assert.match(tree.find(n => n.type === 'DialogContent').props.className, /overflow-y-auto/)
method = 'custom'
tree = render()
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, true)
tree.find(n => n.type === 'Textarea').props.onChange({target: {value: '/opt/my-app/update.sh'}})
tree = render()
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, false)
tree = render({helperAvailable: false})
assert(!tree.some(n => n.type === 'Button' && n.props.children === t('vmLxc.updates.helperMethod')))
method = 'helper'
tree = render({helperAvailable: false})
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, true)
console.log(`PASS ${locale}: explicit selection, navbar blue, dimmed disabled Save, examples heading, local/online scripts, no execution, validation`)
}
// Integrate the editor with the actual parent open/save/cancel functions.
async function testParentRoundTrip() {
const parentSource = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
const ast = ts.createSourceFile('virtual-machines.tsx', parentSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const names = ['openCustomCmdEditor', 'saveCustomCommand', 'closeCustomCmdEditor']
const declarations = {}
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && names.includes(node.name.text)) {
declarations[node.name.text] = `const ${node.getText(ast)};`
}
ts.forEachChild(node, visit)
}
visit(ast)
for (const name of names) assert(declarations[name], `missing actual parent function ${name}`)
const compiled = ts.transpileModule(names.map(n => declarations[n]).join('\n') + '\n' +
names.map(n => `exports.${n} = ${n};`).join('\n'), {compilerOptions: {module: ts.ModuleKind.CommonJS}})
const saved = new Map([
['a', {id: 'a', name: 'Odoo', helper_slug: 'odoo', update_method: 'helper', update_command: ''}],
['b', {id: 'b', name: 'Other app', update_method: 'custom', update_command: '/opt/other/update.sh'}],
])
let writes = 0
const parent = {exports: {}, customCmdDraft: '', updaterMethodDraft: 'none',
setCustomCmdEditingApp: value => {parent.editing = value},
setCustomCmdDraft: value => {parent.customCmdDraft = value},
setUpdaterMethodDraft: value => {parent.updaterMethodDraft = value},
setCustomCmdSaving: value => {parent.saving = value},
patchAppWatch: async (vmid, app, patch) => {
assert.equal(vmid, 101)
saved.set(app.id, {...saved.get(app.id), ...patch})
writes++
}, t: key => key, alert: error => {throw Error(error)},
}
vm.runInNewContext(compiled.outputText, parent)
const render = () => flatten(englishEditor({method: parent.updaterMethodDraft, command: parent.customCmdDraft,
helperAvailable: true, helperSlug: saved.get(parent.editing)?.helper_slug, configured: true,
saving: false, changed: true, onMethodChange: parent.setUpdaterMethodDraft,
onCommandChange: parent.setCustomCmdDraft, onCancel: parent.exports.closeCustomCmdEditor,
onSave: () => {}, onRemove: () => {}}))
parent.exports.openCustomCmdEditor(saved.get('a'))
let field = render().find(n => n.type === 'Textarea')
assert(field.props.value.includes('/ct/odoo.sh'))
const edited = field.props.value.replace('PHS_SILENT=1', 'PHS_SILENT=0')
field.props.onChange({target: {value: edited}})
assert.equal(writes, 0, 'editing must not persist before Save')
parent.exports.closeCustomCmdEditor()
assert.equal(saved.get('a').update_method, 'helper', 'Cancel leaves the original choice unchanged')
parent.exports.openCustomCmdEditor(saved.get('a'))
field = render().find(n => n.type === 'Textarea')
assert(field.props.value.includes('PHS_SILENT=1'))
field.props.onChange({target: {value: edited}})
await parent.exports.saveCustomCommand(101, saved.get('a'))
assert.equal(saved.get('a').update_method, 'custom')
assert.equal(saved.get('a').update_command, edited, 'Save must not discard the edited helper launcher')
parent.exports.openCustomCmdEditor(saved.get('a'))
assert.equal(render().find(n => n.type === 'Textarea').props.value, edited, 'reopen the exact saved custom command')
parent.exports.openCustomCmdEditor(saved.get('b'))
assert.equal(render().find(n => n.type === 'Textarea').props.value, '/opt/other/update.sh', 'keep commands separate per app')
parent.exports.openCustomCmdEditor(saved.get('a'))
parent.setUpdaterMethodDraft('helper')
await parent.exports.saveCustomCommand(101, saved.get('a'))
assert.equal(saved.get('a').update_method, 'helper')
assert.equal(saved.get('a').update_command, '', 'explicitly selecting helper restores the official path')
assert.equal(saved.get('b').update_command, '/opt/other/update.sh')
console.log('PASS actual parent: edit, cancel, save, reopen, per-app isolation, restore helper')
}
testParentRoundTrip().catch(error => {console.error(error); process.exitCode = 1})
@@ -10,7 +10,7 @@
"intro": {
"p1": "The <strong>Updates</strong> tab separates version detection from the action that installs an update. Registration and optional version tracking live on the <link>App tab</link>; executable update methods live here.",
"p2": "A saved application appears in Updates even when it contains only a web link. Version tracking is optional, and an updater can be configured independently.",
"callout": "No action is inferred from an application name alone. ProxMenux runs an integrated method only after verifying it, or a custom command that has been explicitly saved."
"callout": "Registering an application does not select an updater. If a matching Helper-Scripts updater is detected, choose and save <strong>Helper-Scripts</strong> or <strong>Custom command</strong>. Detection is not a compatibility guarantee. The choice belongs to each application, even when several apps share one LXC."
},
"overview": {
"heading": "What the tab contains",
@@ -38,7 +38,7 @@
{
"source": "Proxmox VE Helper-Scripts",
"action": "Apply update",
"notes": "Uses the verified /usr/bin/update wrapper. A legacy marker without a valid wrapper is identified, but never executed automatically."
"notes": "After Helper-Scripts is explicitly selected, runs the official application script identified by the validated /usr/bin/update wrapper. A marker or catalogue match alone never enables execution."
},
{
"source": "Custom command",
@@ -56,7 +56,7 @@
"notes": "Pulls the selected image and recreates its Compose service group or protected standalone container."
}
],
"callout": "A custom command always <strong>replaces</strong> the integrated Proxmox VE Helper-Scripts updater for that application. The two methods are not run one after the other."
"callout": "A custom command <strong>replaces</strong> Helper-Scripts for that application. Manual, bulk and scheduled runs all respect the saved choice. Existing custom commands and legacy explicitly saved bulk/enabled schedule selections are retained; newly registered applications are never opted in by an old schedule."
},
"docker": {
"heading": "Docker Engine and Docker images",
@@ -74,9 +74,9 @@
"heading": "Individual actions and status colours",
"lead": "Every section remains independently actionable, whether or not a bulk update is configured.",
"items": [
"The <strong>Edit</strong> button is always available. Integrated methods open with their current command, which can be reviewed, replaced or cleared.",
"<strong>Configure</strong> opens the updater selector when no method is selected; <strong>Edit</strong> changes a saved choice. The information buttons explain each option, link to official documentation and show a custom-script example. Selecting an option does not execute it.",
"When version tracking is disabled but an updater exists, the neutral <strong>Run updater</strong> action is shown. ProxMenux does not claim that an update is pending.",
"When no method is available, <strong>Configure</strong> opens the custom-command editor.",
"Without an available Helper-Scripts updater, configure a custom command. Disabling a method does not silently select another one; existing plan/schedule selections remain saved and unavailable targets are reported.",
"The <strong>Update image</strong> action applies only to the selected Docker unit; it does not update Docker Engine or unrelated images."
],
"statusColState": "Known state",
@@ -10,7 +10,7 @@
"intro": {
"p1": "La pestaña <strong>Actualizaciones</strong> separa la detección de versiones de la acción que instala una actualización. El registro y el seguimiento opcional viven en la <link>pestaña App</link>; los métodos ejecutables se gestionan aquí.",
"p2": "Una aplicación guardada aparece en Actualizaciones aunque solo contenga un enlace web. El seguimiento de versiones es opcional y el actualizador se puede configurar de forma independiente.",
"callout": "No se deduce una acción únicamente por el nombre de una aplicación. ProxMenux solo ejecuta un método integrado después de verificarlo o un comando personalizado guardado expresamente."
"callout": "Registrar una aplicación no selecciona su actualizador. Si se detecta un actualizador de Helper-Scripts correspondiente a esa app, elige y guarda <strong>Helper-Scripts</strong> o <strong>Comando personalizado</strong>. Detectarlo no garantiza su compatibilidad. La elección pertenece a cada aplicación, aunque varias compartan un LXC."
},
"overview": {
"heading": "Contenido de la pestaña",
@@ -38,7 +38,7 @@
{
"source": "Proxmox VE Helper-Scripts",
"action": "Aplicar actualización",
"notes": "Usa el wrapper /usr/bin/update verificado. Un marcador antiguo sin wrapper válido se identifica, pero nunca se ejecuta automáticamente."
"notes": "Tras seleccionar expresamente Helper-Scripts, ejecuta el script oficial de la aplicación identificado mediante el wrapper /usr/bin/update validado. Un marcador o una coincidencia del catálogo no habilitan la ejecución."
},
{
"source": "Comando personalizado",
@@ -56,7 +56,7 @@
"notes": "Descarga la imagen seleccionada y recrea su grupo de servicios Compose o su contenedor independiente protegido."
}
],
"callout": "Un comando personalizado siempre <strong>reemplaza</strong> al actualizador integrado de Proxmox VE Helper-Scripts para esa aplicación. Los dos métodos no se ejecutan uno detrás de otro."
"callout": "El comando personalizado <strong>reemplaza</strong> a Helper-Scripts para esa aplicación. Las ejecuciones manuales, en bloque y programadas respetan la elección guardada. Se conservan los comandos existentes y las selecciones antiguas guardadas en planes o programaciones activadas; una programación antigua nunca activa el actualizador de una app recién registrada."
},
"docker": {
"heading": "Docker Engine e imágenes Docker",
@@ -74,9 +74,9 @@
"heading": "Acciones individuales y colores de estado",
"lead": "Cada sección conserva su propia acción aunque exista una actualización en bloque configurada.",
"items": [
"El botón <strong>Editar</strong> está siempre disponible. Los métodos integrados muestran su comando actual para poder revisarlo, reemplazarlo o borrarlo.",
"<strong>Configurar</strong> abre el selector cuando no hay un método elegido; <strong>Editar</strong> permite cambiar una elección guardada. Los botones de información explican cada opción, enlazan la documentación oficial y muestran un ejemplo de script personalizado. Seleccionar una opción no la ejecuta.",
"Si el seguimiento de versiones está desactivado pero existe un actualizador, aparece la acción neutra <strong>Ejecutar actualizador</strong>. ProxMenux no afirma que exista una actualización pendiente.",
"Si no existe ningún método, <strong>Configurar</strong> abre el editor del comando personalizado.",
"Sin un actualizador de Helper-Scripts disponible, se puede configurar un comando personalizado. Desactivar un método no selecciona otro silenciosamente; los planes y las programaciones conservan sus selecciones y se informa de los objetivos no disponibles.",
"La acción <strong>Actualizar imagen</strong> solo afecta a la unidad Docker seleccionada; no actualiza Docker Engine ni imágenes no relacionadas."
],
"statusColState": "Estado conocido",
@@ -10,7 +10,7 @@
"intro": {
"p1": "Karta <strong>Aktualizácie</strong> oddeľuje zisťovanie verzie od akcie, ktorá aktualizáciu nainštaluje. Registrácia a voliteľné sledovanie verzie sú na <link>karte Aplikácia</link>; spustiteľné metódy aktualizácie sa spravujú tu.",
"p2": "Uložená aplikácia sa zobrazí v Aktualizáciách aj vtedy, keď obsahuje iba webový odkaz. Sledovanie verzie je voliteľné a aktualizátor možno nastaviť nezávisle.",
"callout": "Akcia sa neurčuje iba podľa názvu aplikácie. ProxMenux spustí integrovanú metódu až po jej overení alebo výslovne uložený vlastný príkaz."
"callout": "Registrácia aplikácie nevyberá aktualizátor. Ak sa zistí zodpovedajúci aktualizátor Helper-Scripts, vyberte a uložte <strong>Helper-Scripts</strong> alebo <strong>Vlastný príkaz</strong>. Zistenie nezaručuje kompatibilitu. Výber patrí každej aplikácii samostatne, aj keď viac aplikácií zdieľa jeden LXC."
},
"overview": {
"heading": "Obsah karty",
@@ -38,7 +38,7 @@
{
"source": "Proxmox VE Helper-Scripts",
"action": "Použiť aktualizáciu",
"notes": "Používa overený wrapper /usr/bin/update. Starý marker bez platného wrappera sa identifikuje, ale automaticky sa nespustí."
"notes": "Po výslovnom výbere Helper-Scripts spustí oficiálny skript aplikácie identifikovaný pomocou overeného wrappera /usr/bin/update. Samotný marker ani zhoda v katalógu spustenie nepovoľujú."
},
{
"source": "Vlastný príkaz",
@@ -56,7 +56,7 @@
"notes": "Stiahne vybraný obraz a znova vytvorí jeho skupinu služieb Compose alebo chránený samostatný kontajner."
}
],
"callout": "Vlastný príkaz vždy <strong>nahrádza</strong> integrovaný aktualizátor Proxmox VE Helper-Scripts pre danú aplikáciu. Obe metódy sa nespúšťajú za sebou."
"callout": "Vlastný príkaz <strong>nahrádza</strong> Helper-Scripts pre danú aplikáciu. Ručné, hromadné aj naplánované spustenia rešpektujú uložený výber. Existujúce príkazy a staršie výslovne uložené výbery v plánoch alebo aktívnych naplánovaných úlohách sa zachovajú; starší plán nikdy neaktivuje aktualizátor novo registrovanej aplikácie."
},
"docker": {
"heading": "Docker Engine a Docker obrazy",
@@ -74,9 +74,9 @@
"heading": "Samostatné akcie a farby stavu",
"lead": "Každá sekcia zostáva samostatne ovládateľná aj po nastavení hromadnej aktualizácie.",
"items": [
"Tlačidlo <strong>Upraviť</strong> je vždy dostupné. Integrované metódy zobrazia aktuálny príkaz, ktorý možno skontrolovať, nahradiť alebo vymazať.",
"<strong>Nastaviť</strong> otvorí výber aktualizátora, ak nebol vybraný žiadny spôsob; <strong>Upraviť</strong> umožní zmeniť uložený výber. Informačné tlačidlá vysvetľujú možnosti, odkazujú na oficiálnu dokumentáciu a zobrazujú príklad vlastného skriptu. Výber možnosti ju nespustí.",
"Ak je sledovanie verzie vypnuté, ale aktualizátor existuje, zobrazí sa neutrálna akcia <strong>Spustiť aktualizátor</strong>. ProxMenux netvrdí, že je dostupná aktualizácia.",
"Ak metóda neexistuje, <strong>Nastaviť</strong> otvorí editor vlastného príkazu.",
"Ak aktualizátor Helper-Scripts nie je dostupný, možno nastaviť vlastný príkaz. Deaktivovanie spôsobu nevyberie potichu iný; plány a naplánované úlohy si zachovajú výber a nedostupné ciele sa oznámia.",
"Akcia <strong>Aktualizovať obraz</strong> ovplyvní iba vybranú jednotku Dockeru, nie Docker Engine ani nesúvisiace obrazy."
],
"statusColState": "Známy stav",