mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-17 20:26:47 +00:00
Improve LXC updater selection, error handling and dashboard consistency
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -96,6 +96,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
|
||||
@@ -457,6 +458,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
|
||||
@@ -681,6 +690,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
logo_url: existing.logo_url || "",
|
||||
helper_slug: existing.helper_slug || "",
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
@@ -95,6 +96,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
|
||||
@@ -108,6 +111,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.
|
||||
@@ -1113,6 +1117,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,
|
||||
@@ -1870,15 +1880,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
|
||||
@@ -1888,6 +1893,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 ──────────────────────────────────
|
||||
@@ -2387,7 +2393,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,
|
||||
@@ -2400,10 +2407,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) }))
|
||||
@@ -5171,7 +5181,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
|
||||
@@ -5234,6 +5244,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) => {
|
||||
@@ -5743,9 +5754,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
|
||||
@@ -5765,12 +5774,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>
|
||||
@@ -5829,7 +5837,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
|
||||
@@ -5846,7 +5857,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}
|
||||
@@ -5907,7 +5918,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>
|
||||
@@ -5919,56 +5930,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 ? (
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -1217,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",
|
||||
@@ -1244,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",
|
||||
@@ -4868,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -1273,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",
|
||||
@@ -4942,5 +4974,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -1217,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",
|
||||
@@ -1244,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",
|
||||
@@ -4868,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 n’ont 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",
|
||||
@@ -1217,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é",
|
||||
@@ -1244,6 +1255,35 @@
|
||||
"osStatusUnavailable": "Impossible de déterminer l’état des mises à jour du système d’exploitation.",
|
||||
"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 l’URL d’exemple 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é n’est 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 qu’une méthode n’aura pas été reconfigurée.",
|
||||
"noUpdaterSelected": "Vous n’avez 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 l’application, un gestionnaire de paquets ou un nouveau binaire. La commande s’exé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 l’application",
|
||||
"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 n’est 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 l’application 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 d’abord le binaire officiel pour la version et l’architecture 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",
|
||||
@@ -4868,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -1217,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",
|
||||
@@ -1244,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 l’URL 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 dell’applicazione, 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 dell’applicazione",
|
||||
"customMethodExample": "Adatta i nomi, i percorsi e il servizio alla tua applicazione. Verrà eseguito solo il comando salvato.",
|
||||
"helperCommandLabel": "Comando all’interno 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 dell’applicazione 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 l’architettura 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",
|
||||
@@ -4868,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -1217,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",
|
||||
@@ -1244,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",
|
||||
@@ -4868,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -1273,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}",
|
||||
@@ -4942,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -1217,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",
|
||||
@@ -1244,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",
|
||||
@@ -4869,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+196
-351
@@ -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'
|
||||
@@ -11877,51 +11877,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
|
||||
@@ -11940,16 +11958,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:
|
||||
@@ -11974,34 +12046,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 = {
|
||||
@@ -12039,28 +12112,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)
|
||||
|
||||
|
||||
@@ -12091,9 +12153,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,
|
||||
@@ -12162,249 +12224,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
|
||||
@@ -13557,13 +13387,9 @@ def api_lxc_updates_detection_set():
|
||||
@require_auth
|
||||
def api_vm_apps_get(vmid):
|
||||
try:
|
||||
cached = _vm_cache_get(_vm_apps_cache, vmid, _VM_APPS_TTL)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
import lxc_apps
|
||||
sidecar = lxc_apps.load_sidecar(vmid)
|
||||
payload = sidecar if sidecar else {'vmid': vmid, 'apps': []}
|
||||
_vm_cache_put(_vm_apps_cache, vmid, payload)
|
||||
return jsonify(payload)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -13578,7 +13404,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
|
||||
@@ -13614,7 +13439,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
|
||||
@@ -13627,7 +13451,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
|
||||
@@ -13640,7 +13463,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
|
||||
@@ -13654,7 +13476,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
|
||||
@@ -13668,7 +13489,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
|
||||
@@ -14056,7 +13876,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
|
||||
@@ -14377,11 +14196,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
|
||||
@@ -15088,18 +14904,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:
|
||||
{
|
||||
@@ -15129,7 +14946,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,
|
||||
@@ -21866,7 +21684,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
|
||||
@@ -21940,7 +21759,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:
|
||||
@@ -21983,6 +21802,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)
|
||||
|
||||
@@ -22020,7 +21840,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'})
|
||||
@@ -22228,6 +22049,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:
|
||||
@@ -22700,22 +22551,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
@@ -26,6 +26,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import copy
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import json
|
||||
@@ -402,15 +403,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
|
||||
|
||||
|
||||
@@ -450,15 +479,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}")
|
||||
@@ -1056,6 +1177,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.
|
||||
@@ -3652,6 +3784,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"),
|
||||
@@ -3661,6 +3794,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.
|
||||
@@ -3868,7 +4002,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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user