mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
Merge develop into PR #337 and resolve shared app cache conflict
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"
|
||||
|
||||
@@ -99,6 +99,7 @@ interface AppConfig {
|
||||
// The backend uses full-record replacement, so omitting these when
|
||||
// editing ports/tracking would silently erase the Updates-tab setup.
|
||||
update_command?: string
|
||||
update_method?: "none" | "helper" | "custom"
|
||||
hide_no_updater_notice?: boolean
|
||||
// Per-app opt-out for the `app_update_available` notification.
|
||||
// Absent / true = notify; false = silenced. Set from the bell
|
||||
@@ -481,6 +482,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vmid, managed])
|
||||
|
||||
useEffect(() => {
|
||||
if (managed) return
|
||||
return subscribeLxcApps(vmid, bundle => {
|
||||
setSidecar(bundle.sidecar)
|
||||
setSuggestions(bundle.suggestions)
|
||||
})
|
||||
}, [vmid, managed])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Live Docker Hub preview. Debounced so typing an image/regex does not
|
||||
@@ -726,6 +735,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
// edits one of its ports.
|
||||
update_via: (existing as { update_via?: string }).update_via || "",
|
||||
update_command: existing.update_command || "",
|
||||
update_method: existing.update_method || (existing.update_command ? "custom" : "none"),
|
||||
hide_no_updater_notice: existing.hide_no_updater_notice === true,
|
||||
notifications_enabled: existing.notifications_enabled !== false,
|
||||
exclude_from_badge: existing.exclude_from_badge === true,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useI18n } from "../lib/i18n/provider"
|
||||
|
||||
export function MetricsCacheNotice({ lastChecked }: { lastChecked: number | null }) {
|
||||
const { language, t } = useI18n()
|
||||
if (lastChecked === null) return null
|
||||
|
||||
return (
|
||||
<p role="status" className="text-xs text-amber-600 dark:text-amber-400 mb-2">
|
||||
{t("overview.metricsCachedWarning", {
|
||||
time: new Date(lastChecked * 1000).toLocaleString(language),
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -104,6 +105,8 @@ interface LxcAppWatch {
|
||||
health_path?: string | null
|
||||
installed_version: string | null
|
||||
latest_version: string | null
|
||||
latest_published_at?: string | null
|
||||
state_revision?: number
|
||||
update_available: boolean | null
|
||||
error: string | null
|
||||
checked_at: string | null
|
||||
@@ -117,6 +120,7 @@ interface LxcAppWatch {
|
||||
// update method. When set, the Updates tab renders an "Apply {app}"
|
||||
// button that runs `pct exec vmid -- sh -c "$update_command"`.
|
||||
update_command?: string
|
||||
update_method?: AppUpdateMethod
|
||||
// Updates tab: per-app dismiss for the "no update method defined"
|
||||
// notice. Only hides the notice — the App tab still shows purple ⬆
|
||||
// when an update is available upstream.
|
||||
@@ -232,13 +236,21 @@ function normalizeVmSearchValue(value: unknown): string {
|
||||
.toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function matchesVmSearch(vm: VMData, terms: string[]): boolean {
|
||||
// Type synonyms come from the active locale so a user can search by the
|
||||
// word they'd naturally use ("contenedor", "machine virtuelle", …) rather
|
||||
// than only the internal type token. "lxc" / "qemu" already match through
|
||||
// vm.type, so the catalog entries only carry the natural-language terms.
|
||||
function matchesVmSearch(
|
||||
vm: VMData,
|
||||
terms: string[],
|
||||
typeSynonyms: { lxc: string; qemu: string },
|
||||
): boolean {
|
||||
if (terms.length === 0) return true
|
||||
const searchable = normalizeVmSearchValue([
|
||||
vm.name,
|
||||
vm.vmid,
|
||||
vm.type,
|
||||
vm.type === "lxc" ? "container kontajner" : "virtual machine virtualny stroj",
|
||||
vm.type === "lxc" ? typeSynonyms.lxc : typeSynonyms.qemu,
|
||||
vm.tags,
|
||||
vm.description,
|
||||
vm.ip,
|
||||
@@ -624,6 +636,9 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
|
||||
})
|
||||
const flags = optsEntries.filter((o) => o.value === null).map((o) => o.key)
|
||||
const keyValues = optsEntries.filter((o) => o.value !== null) as Array<{ key: string; value: string }>
|
||||
const runtimeError = mp.runtime_error === "configured but not mounted"
|
||||
? t("vmLxc.details.mountErrors.configuredButNotMounted")
|
||||
: mp.runtime_error
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-4 ${cardClasses}`}>
|
||||
@@ -792,13 +807,13 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
|
||||
)}
|
||||
|
||||
{/* Error / divergence note. */}
|
||||
{mp.runtime_error && (
|
||||
{runtimeError && (
|
||||
<p
|
||||
className={`mt-3 text-sm ${
|
||||
isStale ? "text-red-400" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{mp.runtime_error}
|
||||
{runtimeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1120,6 +1135,12 @@ export function VirtualMachines() {
|
||||
setSelectedVM(updated)
|
||||
}, [vmData])
|
||||
|
||||
useEffect(() => {
|
||||
for (const vm of vmData || []) {
|
||||
if (vm.type === "lxc") syncLxcAppsState(vm.vmid, vm.app_watches || [])
|
||||
}
|
||||
}, [vmData])
|
||||
|
||||
// Backend lifecycle refreshes are asynchronous: a start response returns
|
||||
// immediately, then the server waits for the guest (and Docker, for LXCs)
|
||||
// before publishing a complete new snapshot. When its revision changes,
|
||||
@@ -1821,15 +1842,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
updates: safeVMData.filter(hasLxcPendingUpdates).length,
|
||||
}), [safeVMData])
|
||||
|
||||
const vmTypeSynonyms = useMemo(() => ({
|
||||
lxc: t("vmLxc.filters.typeSynonyms.lxc"),
|
||||
qemu: t("vmLxc.filters.typeSynonyms.qemu"),
|
||||
}), [t])
|
||||
|
||||
const filteredVMs = useMemo(() => {
|
||||
const statusMatched = statusFilter === "all"
|
||||
? safeVMData
|
||||
: safeVMData.filter((vm) => vm.status === statusFilter)
|
||||
return statusMatched.filter((vm) => (
|
||||
(!updatesOnly || hasLxcPendingUpdates(vm))
|
||||
&& matchesVmSearch(vm, vmSearchTerms)
|
||||
&& matchesVmSearch(vm, vmSearchTerms, vmTypeSynonyms)
|
||||
))
|
||||
}, [safeVMData, statusFilter, updatesOnly, vmSearchTerms])
|
||||
}, [safeVMData, statusFilter, updatesOnly, vmSearchTerms, vmTypeSynonyms])
|
||||
|
||||
// ── LXC update apply flow (Phase 2a/b) ────────────────────────────
|
||||
// Users pick a target (OS, App, both) + backup / restart options,
|
||||
@@ -1872,15 +1898,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// Case-3a "no method" cards the user previously dismissed.
|
||||
const [customCmdEditingApp, setCustomCmdEditingApp] = useState<string | null>(null)
|
||||
const [customCmdDraft, setCustomCmdDraft] = useState<string>("")
|
||||
const [updaterMethodDraft, setUpdaterMethodDraft] = useState<AppUpdateMethod>("none")
|
||||
const [customCmdSaving, setCustomCmdSaving] = useState(false)
|
||||
const [showHiddenNotices, setShowHiddenNotices] = useState(false)
|
||||
|
||||
const canonicalHelperUpdateCommand = (slug?: string | null) => {
|
||||
const cleanSlug = (slug || "").trim()
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(cleanSlug)) return ""
|
||||
return `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${cleanSlug}.sh)"`
|
||||
}
|
||||
|
||||
// Docker Engine is updated by a protected host-side runner rather
|
||||
// than by an arbitrary command inside the CT. Surface the exact
|
||||
// command in the same editor used by every other app, but recognise
|
||||
@@ -1890,6 +1911,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const openCustomCmdEditor = (app: LxcAppWatch, initialCommand = "") => {
|
||||
setCustomCmdEditingApp(app.id)
|
||||
setCustomCmdDraft(app.update_command || initialCommand)
|
||||
setUpdaterMethodDraft(app.update_method || (app.update_command || initialCommand ? "custom" : "none"))
|
||||
}
|
||||
|
||||
// ── Options card unified state ──────────────────────────────────
|
||||
@@ -2389,7 +2411,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setCustomCmdSaving(true)
|
||||
try {
|
||||
await patchAppWatch(vmid, app, {
|
||||
update_command: customCmdDraft.trim(),
|
||||
update_command: updaterMethodDraft === "helper" ? "" : customCmdDraft.trim(),
|
||||
update_method: app.helper_slug === "docker" ? "custom" : updaterMethodDraft,
|
||||
// Saving a command implicitly re-enables the notice (moot —
|
||||
// the notice only shows when there is no command).
|
||||
hide_no_updater_notice: false,
|
||||
@@ -2402,10 +2425,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
}
|
||||
const removeCustomCommand = async (vmid: number, app: LxcAppWatch) => {
|
||||
if (!confirm(t("vmLxc.errors.removeCustomCommandConfirm", { name: app.name }))) return
|
||||
const confirmKey = app.helper_slug === "docker"
|
||||
? "vmLxc.errors.removeCustomCommandConfirm"
|
||||
: "vmLxc.updates.disableUpdaterConfirm"
|
||||
if (!confirm(t(confirmKey, { name: app.name || "" }))) return
|
||||
setCustomCmdSaving(true)
|
||||
try {
|
||||
await patchAppWatch(vmid, app, { update_command: "" })
|
||||
await patchAppWatch(vmid, app, { update_command: "", update_method: "none" })
|
||||
closeCustomCmdEditor()
|
||||
} catch (e) {
|
||||
alert(t("vmLxc.errors.removeCustomCommandFailed", { message: (e as any)?.message || String(e) }))
|
||||
@@ -5206,7 +5232,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const scheduledAppChoices = registeredApps.filter((app) => {
|
||||
if (app.helper_slug === "docker" || app.helper_slug === "adguard") return false
|
||||
if (app.update_command?.trim()) return true
|
||||
return helperExists && app.helper_slug === uc?.helper_slug
|
||||
return helperExists && app.update_method === "helper" && app.helper_slug === uc?.helper_slug
|
||||
}).map((app) => ({ id: `app:${app.id}`, label: app.name }))
|
||||
const versionTrackedScheduleAppIds = new Set(
|
||||
registeredApps
|
||||
@@ -5275,6 +5301,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
if (app.helper_slug === "docker") return false
|
||||
if (app.update_command?.trim()) return true
|
||||
return helperExists
|
||||
&& app.update_method === "helper"
|
||||
&& app.helper_slug === uc?.helper_slug
|
||||
&& app.helper_slug !== "adguard"
|
||||
}).map((app) => {
|
||||
@@ -5801,9 +5828,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{helperSectionDetected && (() => {
|
||||
const matchApp = helperOnlyApps[0] || null
|
||||
if (!matchApp || customCmdEditingApp === matchApp.id) return null
|
||||
const helperEditorCommand = helperExists && !helperUsesWebUpdater
|
||||
? canonicalHelperUpdateCommand(uc?.helper_slug)
|
||||
: ""
|
||||
const helperSelected = matchApp.update_method === "helper"
|
||||
const appWebUrl = helperUsesWebUpdater
|
||||
? buildRegisteredAppUrl(selectedVM, matchApp.ports?.[0])
|
||||
: null
|
||||
@@ -5823,12 +5848,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
type="button"
|
||||
onClick={() => openCustomCmdEditor(
|
||||
matchApp,
|
||||
helperEditorCommand,
|
||||
)}
|
||||
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
{helperEditorCommand
|
||||
{helperSelected
|
||||
? t("vmLxc.updates.editApp")
|
||||
: t("vmLxc.updates.configureUpdater")}
|
||||
</button>
|
||||
@@ -5887,7 +5911,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{t("vmLxc.updates.versionTrackingNotConfigured")}
|
||||
</p>
|
||||
) : null}
|
||||
{helperExists && !helperUsesWebUpdater && (() => {
|
||||
{helperExists && !helperUsesWebUpdater && !helperSelected && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t("vmLxc.updates.noUpdaterSelected")}</p>
|
||||
)}
|
||||
{helperExists && !helperUsesWebUpdater && helperSelected && (() => {
|
||||
// Button state uses the matched
|
||||
// App Watch entry when present.
|
||||
// Without it we DON'T know the
|
||||
@@ -5904,7 +5931,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
|
||||
return (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app", { runHelper: true, appName: helperName || "" })} className={cls}>
|
||||
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app", { runHelper: true, appName: helperName || "", targetIds: [`app:${matchApp.id}`] })} className={cls}>
|
||||
{hasUpd && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{noState && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
{label}
|
||||
@@ -5965,7 +5992,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
{hasCmd
|
||||
{hasCmd || aw.update_method === "helper"
|
||||
? t("vmLxc.updates.editApp")
|
||||
: t("vmLxc.updates.configureUpdater")}
|
||||
</button>
|
||||
@@ -5977,56 +6004,22 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("vmLxc.updates.customCommandLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={customCmdDraft}
|
||||
onChange={(e) => setCustomCmdDraft(e.target.value)}
|
||||
placeholder={t("vmLxc.updates.customCommandPlaceholder")}
|
||||
className="font-mono text-xs mt-2 min-h-[100px]"
|
||||
maxLength={4096}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{hasCmd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCustomCommand(selectedVM.vmid, aw)}
|
||||
disabled={customCmdSaving}
|
||||
className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("vmLxc.updates.removeButton")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeCustomCmdEditor}
|
||||
disabled={customCmdSaving}
|
||||
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
|
||||
>
|
||||
{t("vmLxc.updates.cancelButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveCustomCommand(selectedVM.vmid, aw)}
|
||||
disabled={customCmdSaving || !customCmdDraft.trim() || (
|
||||
customCmdDraft.trim() === (aw.update_command || "").trim()
|
||||
)}
|
||||
className="h-8 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
|
||||
>
|
||||
{customCmdSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
{t("vmLxc.updates.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AppUpdaterEditor
|
||||
method={updaterMethodDraft}
|
||||
command={customCmdDraft}
|
||||
helperAvailable={helperExists && !helperUsesWebUpdater && aw.helper_slug === uc?.helper_slug}
|
||||
helperSlug={aw.helper_slug}
|
||||
configured={hasCmd || aw.update_method === "helper"}
|
||||
saving={customCmdSaving}
|
||||
changed={updaterMethodDraft !== (aw.update_method || (hasCmd ? "custom" : "none")) || (
|
||||
updaterMethodDraft === "custom" && customCmdDraft.trim() !== (aw.update_command || "").trim()
|
||||
)}
|
||||
onMethodChange={setUpdaterMethodDraft}
|
||||
onCommandChange={setCustomCmdDraft}
|
||||
onSave={() => saveCustomCommand(selectedVM.vmid, aw)}
|
||||
onCancel={closeCustomCmdEditor}
|
||||
onRemove={() => removeCustomCommand(selectedVM.vmid, aw)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{hasUpdate ? (
|
||||
|
||||
@@ -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",
|
||||
@@ -882,7 +885,7 @@
|
||||
"executable": "Ausführbar",
|
||||
"workingDir": "Arbeitsverzeichnis",
|
||||
"started": "Begonnen",
|
||||
"runningFor": "Laufen für"
|
||||
"runningFor": "Läuft seit"
|
||||
},
|
||||
"states": {
|
||||
"running": "Läuft",
|
||||
@@ -1030,7 +1033,10 @@
|
||||
"startOnBoot": "Beim Booten beginnen",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "Tag hinzufügen…",
|
||||
"tagsNone": "Keine Tags"
|
||||
"tagsNone": "Keine Tags",
|
||||
"mountErrors": {
|
||||
"configuredButNotMounted": "Konfiguriert, aber nicht gemountet"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"header": "Protokolle für {name} (VMID: {vmid})",
|
||||
@@ -1214,7 +1220,15 @@
|
||||
"humanWeekly": "Wöchentlich ({day} {time})",
|
||||
"humanMonthly": "Monatlich (Tag {day} um {time})",
|
||||
"humanHourly": "Stündlich",
|
||||
"weekdays": "['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']"
|
||||
"weekdays": [
|
||||
"Sonntag",
|
||||
"Montag",
|
||||
"Dienstag",
|
||||
"Mittwoch",
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
"Samstag"
|
||||
]
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "Host-Cron erkannt",
|
||||
@@ -1241,6 +1255,35 @@
|
||||
"osStatusUnavailable": "Der Status der Betriebssystemaktualisierungen konnte nicht ermittelt werden.",
|
||||
"installedByHelperPrefix": "Installiert von",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperCommandEditHint": "Wenn du diesen Aufruf bearbeitest, wird er als benutzerdefinierter Befehl gespeichert.",
|
||||
"customOnlineDescription": "Oder verwende ein vom Entwickler bereitgestelltes Online-Skript:",
|
||||
"customOnlineExampleNote": "Ersetze die Beispiel-URL durch die URL des offiziellen Skripts deiner Anwendung.",
|
||||
"customExamplesHeading": "Beispiele zum Anpassen:",
|
||||
"updaterChoiceHint": "Wähle eine Aktualisierungsmethode und speichere sie.",
|
||||
"updaterMethodLabel": "Aktualisierungsmethode",
|
||||
"helperMethod": "Helper-Scripts",
|
||||
"customMethod": "Eigener Befehl",
|
||||
"helperMethodHelp": "Über Helper-Scripts",
|
||||
"customMethodHelp": "Über eigene Befehle",
|
||||
"helperDetectedChoice": "Helper-Scripts-Updater für diese Anwendung erkannt.",
|
||||
"helperUnavailableChoice": "Der ausgewählte Helper-Scripts-Updater ist nicht mehr verfügbar.",
|
||||
"disableUpdater": "Deaktivieren",
|
||||
"disableUpdaterConfirm": "Aktualisierungsmethode für {name} deaktivieren? Gespeicherte Pläne und Zeitpläne behalten ihre Auswahl, führen diese Anwendung aber erst wieder aus, wenn eine Methode konfiguriert ist.",
|
||||
"noUpdaterSelected": "Du hast keine Aktualisierungsmethode ausgewählt.",
|
||||
"helperMethodDescription": "ProxMenux führt den erkannten Updater in diesem LXC aus und zeigt dessen Protokoll und Ergebnis. Der Updater wird von Proxmox VE Helper-Scripts gepflegt.",
|
||||
"customMethodDescription": "Du kannst den Updater der Anwendung, einen Paketmanager oder eine neue Binärdatei verwenden. Der Befehl wird in diesem LXC mit Administratorrechten ausgeführt.",
|
||||
"updaterInstructions": "Lies die Hinweise des Updaters.",
|
||||
"helperDocumentation": "Aktualisierungsdokumentation",
|
||||
"helperSource": "Anwendungsskript",
|
||||
"customMethodExample": "Passe Namen, Pfade und Dienst an deine Anwendung an. Nur der gespeicherte Befehl wird ausgeführt.",
|
||||
"helperCommandLabel": "Befehl innerhalb des LXC",
|
||||
"helperCommandFallback": "Wenn wget nicht verfügbar ist, lädt ProxMenux dasselbe Skript mit curl -fsSL herunter.",
|
||||
"customScriptTitle": "Aktualisierungsskript",
|
||||
"customScriptDescription": "Führe ein bereits im LXC vorhandenes Aktualisierungsskript der Anwendung aus.",
|
||||
"customPackageTitle": "Debian/Ubuntu-Paket",
|
||||
"customPackageDescription": "Ersetze my-package durch das Paket, das du aktualisieren möchtest.",
|
||||
"customBinaryTitle": "Binärdatei",
|
||||
"customBinaryDescription": "Lade zuerst die offizielle Binärdatei für die richtige Version und Architektur nach /tmp/my-app.new herunter und überprüfe sie. Dieses Beispiel sichert die bisherige Binärdatei und startet den zugehörigen Dienst neu.",
|
||||
"helperUpdatesRun": "Installiert von Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "installiert",
|
||||
"upstreamAvailable": "Version {version} verfügbar",
|
||||
@@ -1564,22 +1607,26 @@
|
||||
"excludeFromBadgeHelp": "Zählen Sie diese App nicht im Abzeichen „Aggregate Updates“ auf der LXC-Listenkarte.Nützlich, wenn Sie absichtlich an eine bestimmte Version gebunden sind (Tracker-Anforderung, Kompatibilitätsstopp).Hat keinen Einfluss auf den eigenen Status der App-Registerkarte oder die ausgehende Benachrichtigung."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Virtuelle Maschinen und Container nach Status filtern",
|
||||
"ariaLabel": "Virtuelle Maschinen und Container filtern",
|
||||
"all": "Alle",
|
||||
"running": "Laufen",
|
||||
"running": "Läuft",
|
||||
"stopped": "Gestoppt",
|
||||
"empty": "Keine virtuellen Maschinen oder Container mit dem Status „{status}“"
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "Nach Name, ID, Tag oder Notiz suchen …",
|
||||
"searchAriaLabel": "Durchsuchen Sie virtuelle Maschinen und Container",
|
||||
"searchPlaceholder": "Name, ID, Tag oder Notiz suchen…",
|
||||
"searchAriaLabel": "Virtuelle Maschinen und Container durchsuchen",
|
||||
"clearSearch": "Suche löschen",
|
||||
"updates": "Aktualisierungen",
|
||||
"updates": "Updates",
|
||||
"resultCount": "{shown} von {total} Maschinen",
|
||||
"noMatches": "Keine VMs oder LXCs entsprechen Ihrer Suche.",
|
||||
"noMatches": "Keine VMs oder LXCs entsprechen der Suche.",
|
||||
"noRunning": "Keine laufenden VMs oder LXCs.",
|
||||
"noStopped": "Keine gestoppten VMs oder LXCs.",
|
||||
"noUpdates": "Nein LXC mit verfügbaren Updates."
|
||||
"noUpdates": "Kein LXC mit verfügbaren Updates.",
|
||||
"typeSynonyms": {
|
||||
"lxc": "container",
|
||||
"qemu": "vm virtuelle maschine virtuelle maschinen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -4062,7 +4109,7 @@
|
||||
"loadingJob": "Job wird geladen...",
|
||||
"manualOneShot": "manuell / One-Shot",
|
||||
"manualOneShotDescription": "One-Shot-Backup – zum Zeitpunkt des Auslösers erfasst. Kann nicht erneut ausgeführt oder bearbeitet werden.",
|
||||
"neverRun": "niemals laufen",
|
||||
"neverRun": "nie ausgeführt",
|
||||
"newScheduledJob": "Neuer geplanter Auftrag",
|
||||
"newScheduledJobDescription": "Erstellen Sie einen wiederkehrenden Host-Sicherungsauftrag.",
|
||||
"nextRun": "Nächster Lauf",
|
||||
@@ -4193,7 +4240,7 @@
|
||||
"inProgress": "Manuelle Sicherung läuft",
|
||||
"oneShotDescription": "One-Shot-Backups werden als eingefrorene Jobs aufbewahrt, sodass Sie ihr Protokoll später überprüfen können.",
|
||||
"reopenLogTitle": "Protokoll erneut öffnen",
|
||||
"run": "Laufen",
|
||||
"run": "Ausführen",
|
||||
"runBackup": "Führen Sie ein Backup aus",
|
||||
"runOneShotBackup": "Führen Sie ein One-Shot-Backup durch",
|
||||
"title": "Manuelle Sicherung",
|
||||
@@ -4349,7 +4396,7 @@
|
||||
"hourlyHelpBefore": "Im Moment",
|
||||
"hourlyHelpMiddle": "der Timer",
|
||||
"hourlyOption": "Stündlich",
|
||||
"howToSchedule": "Wie soll es laufen?",
|
||||
"howToSchedule": "Wie soll es ausgeführt werden?",
|
||||
"janFirst": "Jedes Jahr am 1. Januar",
|
||||
"modeHelp": "Wählen Sie einen einfachen Zeitplan oder geben Sie einen systemd-OnCalendar-Ausdruck ein.",
|
||||
"monthlyOption": "Monatlich",
|
||||
@@ -4861,5 +4908,134 @@
|
||||
"customLinkDelete": "Delete",
|
||||
"customLinkSaveError": "Save failed",
|
||||
"customLinkDeleteError": "Delete failed"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit & Report",
|
||||
"loading": "Loading assessment…",
|
||||
"run": "Run assessment",
|
||||
"running": "Assessing…",
|
||||
"neverRun": "This host has not been assessed yet.",
|
||||
"lastRun": "Last assessed on {when}",
|
||||
"stale": "{days} days ago",
|
||||
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"showPassing": "Show passing checks",
|
||||
"hidePassing": "Hide passing checks",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
"accepted": "Accepted risk",
|
||||
"pass": "Passed",
|
||||
"not_applicable": "Not applicable"
|
||||
},
|
||||
"areas": {
|
||||
"all": "All",
|
||||
"system": "System",
|
||||
"storage": "Storage",
|
||||
"network": "Network",
|
||||
"security": "Security",
|
||||
"backup": "Backup",
|
||||
"guests": "Guests",
|
||||
"hardware": "Hardware"
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk"
|
||||
},
|
||||
"errors": {
|
||||
"runFailed": "The assessment could not be started."
|
||||
},
|
||||
"checks": {
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Backup coverage",
|
||||
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
|
||||
"summary": {
|
||||
"noJobs": "No backup job is defined on this node",
|
||||
"covered": "All {total} guests are covered by a backup job",
|
||||
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pending_reboot": {
|
||||
"title": "Restart state",
|
||||
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
|
||||
"summary": {
|
||||
"none": "No restart is pending",
|
||||
"pending": "The host has a pending restart"
|
||||
}
|
||||
},
|
||||
"enterprise_repo_without_subscription": {
|
||||
"title": "Enterprise repository",
|
||||
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
|
||||
"summary": {
|
||||
"notEnabled": "The enterprise repository is not enabled",
|
||||
"subscribed": "The enterprise repository is backed by a subscription",
|
||||
"unsubscribed": "The enterprise repository is enabled without an active subscription"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guests": {
|
||||
"privileged_containers": {
|
||||
"title": "Container privileges",
|
||||
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
|
||||
"summary": {
|
||||
"allUnprivileged": "All {total} containers are unprivileged",
|
||||
"privileged": "{count} of {total} containers run privileged"
|
||||
}
|
||||
},
|
||||
"qemu_without_agent": {
|
||||
"title": "Guest agent on virtual machines",
|
||||
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
|
||||
"summary": {
|
||||
"allHaveAgent": "All {total} virtual machines declare the guest agent",
|
||||
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"host_firewall_enabled": {
|
||||
"title": "Firewall state",
|
||||
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
|
||||
"summary": {
|
||||
"bothEnabled": "The firewall is enabled at datacenter and node level",
|
||||
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
|
||||
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"orphaned_volumes": {
|
||||
"title": "Volume assignment",
|
||||
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
|
||||
"summary": {
|
||||
"none": "No orphaned volumes were found",
|
||||
"found": "{count} volume(s) belong to no existing guest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"profile": "Profile",
|
||||
"node": "Node",
|
||||
"admin": "Admin",
|
||||
"menu": "Navigation Menu"
|
||||
"menu": "Navigation Menu",
|
||||
"audit": "Audit & Report"
|
||||
},
|
||||
"status": {
|
||||
"healthy": "Healthy",
|
||||
@@ -71,6 +72,8 @@
|
||||
"networkErrorWhileSaving": "Network error while saving"
|
||||
},
|
||||
"overview": {
|
||||
"metricsTimeout": "The metrics query exceeded 30 seconds. ProxMenux will retry automatically.",
|
||||
"metricsCachedWarning": "Could not refresh metrics. Showing data retrieved at {time}.",
|
||||
"loadingTitle": "Loading system overview...",
|
||||
"loadingDescription": "Fetching system status and metrics",
|
||||
"topProcessesCpu": "View top processes by CPU",
|
||||
@@ -932,7 +935,11 @@
|
||||
"noMatches": "No VMs or LXCs match your search.",
|
||||
"noRunning": "No running VMs or LXCs.",
|
||||
"noStopped": "No stopped VMs or LXCs.",
|
||||
"noUpdates": "No LXC with available updates."
|
||||
"noUpdates": "No LXC with available updates.",
|
||||
"typeSynonyms": {
|
||||
"lxc": "container containers",
|
||||
"qemu": "vm vms virtual machine virtual machines"
|
||||
}
|
||||
},
|
||||
"uptime": "Uptime: {uptime}",
|
||||
"cpuUsage": "CPU Usage",
|
||||
@@ -1044,6 +1051,9 @@
|
||||
"stopped": "stopped",
|
||||
"mounted": "mounted"
|
||||
},
|
||||
"mountErrors": {
|
||||
"configuredButNotMounted": "Configured but not mounted"
|
||||
},
|
||||
"startOnBoot": "Start at boot",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "Add tag…",
|
||||
@@ -1231,7 +1241,15 @@
|
||||
"humanWeekly": "Weekly ({day} {time})",
|
||||
"humanMonthly": "Monthly (day {day} at {time})",
|
||||
"humanHourly": "Hourly",
|
||||
"weekdays": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
||||
"weekdays": [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday"
|
||||
]
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "host cron detected",
|
||||
@@ -1258,6 +1276,35 @@
|
||||
"osStatusUnavailable": "The OS update status could not be determined.",
|
||||
"installedByHelperPrefix": "Installed by",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperCommandEditHint": "If you edit this launcher, it will be saved as a custom command.",
|
||||
"customOnlineDescription": "Or use an online script provided by the developer:",
|
||||
"customOnlineExampleNote": "Replace the example URL with the official script URL for your application.",
|
||||
"customExamplesHeading": "Examples to adapt:",
|
||||
"updaterChoiceHint": "Choose and save an update method.",
|
||||
"updaterMethodLabel": "Update method",
|
||||
"helperMethod": "Helper-Scripts",
|
||||
"customMethod": "Custom command",
|
||||
"helperMethodHelp": "About Helper-Scripts",
|
||||
"customMethodHelp": "About custom commands",
|
||||
"helperDetectedChoice": "Helper-Scripts updater detected for this application.",
|
||||
"helperUnavailableChoice": "The selected Helper-Scripts updater is no longer available.",
|
||||
"disableUpdater": "Disable",
|
||||
"disableUpdaterConfirm": "Disable the update method for {name}? Saved plans and schedules will keep their selections, but this application will not run until a method is configured again.",
|
||||
"noUpdaterSelected": "You have not selected an update method.",
|
||||
"helperMethodDescription": "ProxMenux runs the detected updater inside this LXC and displays its log and result. The updater is maintained by Proxmox VE Helper-Scripts.",
|
||||
"customMethodDescription": "You can use the application's own updater, a package manager or a new binary. The command runs inside this LXC with administrator privileges.",
|
||||
"updaterInstructions": "Review the updater's instructions.",
|
||||
"helperDocumentation": "Update documentation",
|
||||
"helperSource": "Application script",
|
||||
"customMethodExample": "Adapt the names, paths and service to your application. Only the command you save will run.",
|
||||
"helperCommandLabel": "Command inside the LXC",
|
||||
"helperCommandFallback": "If wget is unavailable, ProxMenux uses curl -fsSL to download the same script.",
|
||||
"customScriptTitle": "Update script",
|
||||
"customScriptDescription": "Run an existing application update script inside the LXC.",
|
||||
"customPackageTitle": "Debian/Ubuntu package",
|
||||
"customPackageDescription": "Replace my-package with the package you want to update.",
|
||||
"customBinaryTitle": "Binary",
|
||||
"customBinaryDescription": "First download and verify the official binary for the correct version and architecture at /tmp/my-app.new. This example keeps a backup of the previous binary and restarts its service.",
|
||||
"helperUpdatesRun": "Installed by Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "installed",
|
||||
"upstreamAvailable": "upstream {version} available",
|
||||
@@ -4933,5 +4980,134 @@
|
||||
"customLinkDelete": "Delete",
|
||||
"customLinkSaveError": "Save failed",
|
||||
"customLinkDeleteError": "Delete failed"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit & Report",
|
||||
"loading": "Loading assessment…",
|
||||
"run": "Run assessment",
|
||||
"running": "Assessing…",
|
||||
"neverRun": "This host has not been assessed yet.",
|
||||
"lastRun": "Last assessed on {when}",
|
||||
"stale": "{days} days ago",
|
||||
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"showPassing": "Show passing checks",
|
||||
"hidePassing": "Hide passing checks",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
"accepted": "Accepted risk",
|
||||
"pass": "Passed",
|
||||
"not_applicable": "Not applicable"
|
||||
},
|
||||
"areas": {
|
||||
"all": "All",
|
||||
"system": "System",
|
||||
"storage": "Storage",
|
||||
"network": "Network",
|
||||
"security": "Security",
|
||||
"backup": "Backup",
|
||||
"guests": "Guests",
|
||||
"hardware": "Hardware"
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk"
|
||||
},
|
||||
"errors": {
|
||||
"runFailed": "The assessment could not be started."
|
||||
},
|
||||
"checks": {
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Backup coverage",
|
||||
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
|
||||
"summary": {
|
||||
"noJobs": "No backup job is defined on this node",
|
||||
"covered": "All {total} guests are covered by a backup job",
|
||||
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pending_reboot": {
|
||||
"title": "Restart state",
|
||||
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
|
||||
"summary": {
|
||||
"none": "No restart is pending",
|
||||
"pending": "The host has a pending restart"
|
||||
}
|
||||
},
|
||||
"enterprise_repo_without_subscription": {
|
||||
"title": "Enterprise repository",
|
||||
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
|
||||
"summary": {
|
||||
"notEnabled": "The enterprise repository is not enabled",
|
||||
"subscribed": "The enterprise repository is backed by a subscription",
|
||||
"unsubscribed": "The enterprise repository is enabled without an active subscription"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guests": {
|
||||
"privileged_containers": {
|
||||
"title": "Container privileges",
|
||||
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
|
||||
"summary": {
|
||||
"allUnprivileged": "All {total} containers are unprivileged",
|
||||
"privileged": "{count} of {total} containers run privileged"
|
||||
}
|
||||
},
|
||||
"qemu_without_agent": {
|
||||
"title": "Guest agent on virtual machines",
|
||||
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
|
||||
"summary": {
|
||||
"allHaveAgent": "All {total} virtual machines declare the guest agent",
|
||||
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"host_firewall_enabled": {
|
||||
"title": "Firewall state",
|
||||
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
|
||||
"summary": {
|
||||
"bothEnabled": "The firewall is enabled at datacenter and node level",
|
||||
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
|
||||
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"orphaned_volumes": {
|
||||
"title": "Volume assignment",
|
||||
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
|
||||
"summary": {
|
||||
"none": "No orphaned volumes were found",
|
||||
"found": "{count} volume(s) belong to no existing guest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"profile": "Perfil",
|
||||
"node": "Nodo",
|
||||
"admin": "Administración",
|
||||
"menu": "Menú de navegación"
|
||||
"menu": "Menú de navegación",
|
||||
"audit": "Auditoría e informes"
|
||||
},
|
||||
"status": {
|
||||
"healthy": "OK",
|
||||
@@ -72,6 +73,8 @@
|
||||
"networkErrorWhileSaving": "Error de red al guardar"
|
||||
},
|
||||
"overview": {
|
||||
"metricsTimeout": "La consulta de métricas ha superado los 30 segundos. ProxMenux volverá a intentarlo automáticamente.",
|
||||
"metricsCachedWarning": "No se han podido actualizar las métricas. Se muestran los datos obtenidos el {time}.",
|
||||
"loadingTitle": "Cargando descripción general del sistema...",
|
||||
"loadingDescription": "Obtener el estado y las métricas del sistema",
|
||||
"topProcessesCpu": "Ver procesos principales por CPU",
|
||||
@@ -882,7 +885,7 @@
|
||||
"executable": "Ejecutable",
|
||||
"workingDir": "Directorio de trabajo",
|
||||
"started": "Comenzó",
|
||||
"runningFor": "Corriendo por"
|
||||
"runningFor": "En ejecución desde"
|
||||
},
|
||||
"states": {
|
||||
"running": "En ejecución",
|
||||
@@ -1030,7 +1033,10 @@
|
||||
"startOnBoot": "Iniciar al arrancar",
|
||||
"tags": "Etiquetas",
|
||||
"tagsPlaceholder": "Añadir etiqueta…",
|
||||
"tagsNone": "Sin etiquetas"
|
||||
"tagsNone": "Sin etiquetas",
|
||||
"mountErrors": {
|
||||
"configuredButNotMounted": "Configurado pero no montado"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"header": "Registros para {name} (VMID: {vmid})",
|
||||
@@ -1214,7 +1220,15 @@
|
||||
"humanWeekly": "Semanal ({day} {time})",
|
||||
"humanMonthly": "Mensual (día {day} a las {time})",
|
||||
"humanHourly": "cada hora",
|
||||
"weekdays": "['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado']"
|
||||
"weekdays": [
|
||||
"domingo",
|
||||
"lunes",
|
||||
"martes",
|
||||
"miércoles",
|
||||
"jueves",
|
||||
"viernes",
|
||||
"sábado"
|
||||
]
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron del host detectado",
|
||||
@@ -1241,6 +1255,35 @@
|
||||
"osStatusUnavailable": "No se ha podido determinar el estado de las actualizaciones del SO.",
|
||||
"installedByHelperPrefix": "Instalado por",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperCommandEditHint": "Si modificas este lanzador, se guardará como comando personalizado.",
|
||||
"customOnlineDescription": "O utiliza un script online proporcionado por el desarrollador:",
|
||||
"customOnlineExampleNote": "Sustituye la URL de ejemplo por la del script oficial de tu aplicación.",
|
||||
"customExamplesHeading": "Ejemplos para adaptar:",
|
||||
"updaterChoiceHint": "Elige y guarda un método de actualización.",
|
||||
"updaterMethodLabel": "Método de actualización",
|
||||
"helperMethod": "Helper-Scripts",
|
||||
"customMethod": "Comando personalizado",
|
||||
"helperMethodHelp": "Acerca de Helper-Scripts",
|
||||
"customMethodHelp": "Acerca del comando personalizado",
|
||||
"helperDetectedChoice": "Actualizador de Helper-Scripts detectado para esta aplicación.",
|
||||
"helperUnavailableChoice": "El actualizador de Helper-Scripts seleccionado ya no está disponible.",
|
||||
"disableUpdater": "Desactivar",
|
||||
"disableUpdaterConfirm": "¿Desactivar el método de actualización de {name}? Los planes y las programaciones conservarán sus selecciones, pero esta aplicación no se ejecutará hasta que vuelvas a configurar un método.",
|
||||
"noUpdaterSelected": "No has seleccionado un método de actualización.",
|
||||
"helperMethodDescription": "ProxMenux ejecuta el actualizador detectado dentro de este LXC y muestra su registro y resultado. Este lo mantiene Proxmox VE Helper-Scripts.",
|
||||
"customMethodDescription": "Puedes utilizar el actualizador de la aplicación, un gestor de paquetes o un binario nuevo. El comando se ejecuta dentro de este LXC con privilegios de administrador.",
|
||||
"updaterInstructions": "Revisa las indicaciones del actualizador.",
|
||||
"helperDocumentation": "Documentación de actualización",
|
||||
"helperSource": "Script de la aplicación",
|
||||
"customMethodExample": "Adapta los nombres, las rutas y el servicio a tu aplicación. Solo se ejecutará el comando que guardes.",
|
||||
"helperCommandLabel": "Comando dentro del LXC",
|
||||
"helperCommandFallback": "Si wget no está disponible, ProxMenux utiliza curl -fsSL para descargar el mismo script.",
|
||||
"customScriptTitle": "Script de actualización",
|
||||
"customScriptDescription": "Ejecuta un script de actualización de la aplicación que ya exista en el LXC.",
|
||||
"customPackageTitle": "Paquete Debian/Ubuntu",
|
||||
"customPackageDescription": "Sustituye my-package por el paquete que quieras actualizar.",
|
||||
"customBinaryTitle": "Binario",
|
||||
"customBinaryDescription": "Primero descarga y verifica el binario oficial de la versión y arquitectura correctas en /tmp/my-app.new. Este ejemplo conserva una copia del binario anterior y reinicia su servicio.",
|
||||
"helperUpdatesRun": "Instalado por Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "instalado",
|
||||
"upstreamAvailable": "versión {version} disponible",
|
||||
@@ -1564,22 +1607,26 @@
|
||||
"excludeFromBadgeHelp": "No sumar esta app al contador agregado de actualizaciones del card del LXC. Útil cuando mantienes una versión concreta a propósito (requisito de tracker, compatibilidad). No afecta al estado que se muestra en la pestaña App ni al envío de la notificación."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "filtrar máquinas virtuales y contenedores por estado",
|
||||
"ariaLabel": "Filtrar máquinas virtuales y contenedores",
|
||||
"all": "Todos",
|
||||
"running": "corriendo",
|
||||
"stopped": "detenido",
|
||||
"running": "En ejecución",
|
||||
"stopped": "Detenido",
|
||||
"empty": "No hay máquinas virtuales ni contenedores con estado \"{status}\""
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "buscar nombre, ID, etiqueta o nota...",
|
||||
"searchAriaLabel": "buscar máquinas virtuales y contenedores",
|
||||
"searchPlaceholder": "Buscar por nombre, ID, etiqueta o nota…",
|
||||
"searchAriaLabel": "Buscar máquinas virtuales y contenedores",
|
||||
"clearSearch": "Borrar búsqueda",
|
||||
"updates": "Actualizaciones",
|
||||
"resultCount": "{shown} de {total} máquinas",
|
||||
"noMatches": "Ninguna VM o LXC coincide con su búsqueda.",
|
||||
"noRunning": "No se ejecutan VM ni LXC.",
|
||||
"noStopped": "No hay máquinas virtuales ni LXC detenidos.",
|
||||
"noUpdates": "No LXC con actualizaciones disponibles."
|
||||
"noMatches": "Ninguna VM ni LXC coincide con la búsqueda.",
|
||||
"noRunning": "No hay VM ni LXC en ejecución.",
|
||||
"noStopped": "No hay VM ni LXC detenidas.",
|
||||
"noUpdates": "Ningún LXC tiene actualizaciones disponibles.",
|
||||
"typeSynonyms": {
|
||||
"lxc": "contenedor contenedores",
|
||||
"qemu": "vm máquina virtual máquinas virtuales"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -2714,7 +2761,7 @@
|
||||
"executiveSummary": "Resumen ejecutivo",
|
||||
"hardeningAssessment": "Evaluación de endurecimiento",
|
||||
"auditOf": "Auditoría de",
|
||||
"running": "corriendo",
|
||||
"running": "ejecutándose en",
|
||||
"unknownOs": "sistema operativo desconocido",
|
||||
"noActionableWarnings": "Sin advertencias procesables",
|
||||
"and": "y",
|
||||
@@ -4861,5 +4908,134 @@
|
||||
"customLinkDelete": "Eliminar",
|
||||
"customLinkSaveError": "Error al guardar",
|
||||
"customLinkDeleteError": "Error al eliminar"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Auditoría e informes",
|
||||
"loading": "Cargando evaluación…",
|
||||
"run": "Ejecutar evaluación",
|
||||
"running": "Evaluando…",
|
||||
"neverRun": "Este host aún no se ha evaluado.",
|
||||
"lastRun": "Última evaluación el {when}",
|
||||
"stale": "hace {days} días",
|
||||
"readOnlyNotice": "La evaluación solo lee el host. No realiza ningún cambio.",
|
||||
"noFindings": "Ningún hallazgo coincide con el filtro actual.",
|
||||
"showPassing": "Mostrar comprobaciones correctas",
|
||||
"hidePassing": "Ocultar comprobaciones correctas",
|
||||
"affectedCount": "{count} afectados",
|
||||
"acceptedNotice": "{count} riesgo(s) aceptado(s) registrados en este host.",
|
||||
"states": {
|
||||
"fail": "Fallo",
|
||||
"warn": "Aviso",
|
||||
"accepted": "Riesgo aceptado",
|
||||
"pass": "Correcto",
|
||||
"not_applicable": "No aplicable"
|
||||
},
|
||||
"areas": {
|
||||
"all": "Todas",
|
||||
"system": "Sistema",
|
||||
"storage": "Almacenamiento",
|
||||
"network": "Red",
|
||||
"security": "Seguridad",
|
||||
"backup": "Backups",
|
||||
"guests": "Invitados",
|
||||
"hardware": "Hardware"
|
||||
},
|
||||
"detail": {
|
||||
"why": "Contexto",
|
||||
"evidence": "Evidencia",
|
||||
"affected": "Afectados",
|
||||
"acceptedRisk": "Riesgo aceptado"
|
||||
},
|
||||
"errors": {
|
||||
"runFailed": "No se pudo iniciar la evaluación."
|
||||
},
|
||||
"checks": {
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Cobertura de backups",
|
||||
"rationale": "Compara el inventario de invitados con la selección de cada trabajo de backup. Un trabajo selecciona por lista de VMID, por pool o con `all 1`, restando su lista `exclude`. Los trabajos con `enabled 0` no se consideran.",
|
||||
"summary": {
|
||||
"noJobs": "No hay ningún trabajo de backup definido en este nodo",
|
||||
"covered": "Los {total} invitados están cubiertos por un trabajo de backup",
|
||||
"uncovered": "{count} de {total} invitados no están cubiertos por ningún trabajo de backup activo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pending_reboot": {
|
||||
"title": "Estado de reinicio",
|
||||
"rationale": "Comprueba la presencia de `/var/run/reboot-required` y, cuando existe, los paquetes listados en `/var/run/reboot-required.pkgs`. Informa también del kernel en ejecución.",
|
||||
"summary": {
|
||||
"none": "No hay ningún reinicio pendiente",
|
||||
"pending": "El host tiene un reinicio pendiente"
|
||||
}
|
||||
},
|
||||
"enterprise_repo_without_subscription": {
|
||||
"title": "Repositorio enterprise",
|
||||
"rationale": "Busca referencias a `enterprise.proxmox.com` en `/etc/apt/sources.list` y en `sources.list.d`, y contrasta el resultado con el estado que devuelve `pvesubscription get`.",
|
||||
"summary": {
|
||||
"notEnabled": "El repositorio enterprise no está habilitado",
|
||||
"subscribed": "El repositorio enterprise cuenta con suscripción",
|
||||
"unsubscribed": "El repositorio enterprise está habilitado sin suscripción activa"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guests": {
|
||||
"privileged_containers": {
|
||||
"title": "Privilegios de los contenedores",
|
||||
"rationale": "Revisa la configuración de cada contenedor. Proxmox marca los contenedores sin privilegios con `unprivileged: 1`; su ausencia indica un contenedor privilegiado, que comparte el espacio de nombres de usuario del host.",
|
||||
"summary": {
|
||||
"allUnprivileged": "Los {total} contenedores son sin privilegios",
|
||||
"privileged": "{count} de {total} contenedores se ejecutan con privilegios"
|
||||
}
|
||||
},
|
||||
"qemu_without_agent": {
|
||||
"title": "Agente invitado en máquinas virtuales",
|
||||
"rationale": "Revisa la configuración de cada máquina virtual en busca de `agent: 1`. El agente invitado habilita el apagado ordenado, la congelación del sistema de archivos para instantáneas y el informe de uso real de disco.",
|
||||
"summary": {
|
||||
"allHaveAgent": "Las {total} máquinas virtuales declaran el agente invitado",
|
||||
"missingAgent": "{count} de {total} máquinas virtuales no declaran el agente invitado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"host_firewall_enabled": {
|
||||
"title": "Estado del firewall",
|
||||
"rationale": "Comprueba `enable: 1` en `/etc/pve/firewall/cluster.fw` y en el `host.fw` del nodo. Proxmox aplica las reglas del nodo solo mientras el interruptor del centro de datos está activo.",
|
||||
"summary": {
|
||||
"bothEnabled": "El firewall está habilitado en el centro de datos y en el nodo",
|
||||
"datacenterOff": "El firewall está deshabilitado en el centro de datos, así que las reglas del nodo no se aplican",
|
||||
"nodeOff": "El firewall está habilitado en el centro de datos pero no en este nodo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"orphaned_volumes": {
|
||||
"title": "Asignación de volúmenes",
|
||||
"rationale": "Compara los volúmenes que devuelve `pvesm list` con las configuraciones de invitado existentes. Solo examina almacenamiento no compartido: en almacenamiento compartido un volumen puede pertenecer a un invitado que se ejecuta en otro nodo.",
|
||||
"summary": {
|
||||
"none": "No se han encontrado volúmenes huérfanos",
|
||||
"found": "{count} volumen(es) no pertenecen a ningún invitado existente"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summaryFallback": "No se pudo evaluar la comprobación",
|
||||
"acceptRisk": {
|
||||
"action": "Aceptar riesgo",
|
||||
"revoke": "Volver a activo",
|
||||
"title": "Aceptar este riesgo",
|
||||
"reasonLabel": "Motivo",
|
||||
"reasonHelp": "Obligatorio. Queda registrado junto al autor y la fecha.",
|
||||
"reasonPlaceholder": "p. ej. Contenedores de laboratorio, sin cobertura a propósito",
|
||||
"expiryLabel": "Revisar dentro de",
|
||||
"expiryHelp": "Al cumplirse el plazo el hallazgo vuelve a estado activo.",
|
||||
"expiryNever": "No caduca",
|
||||
"expiry90": "90 días",
|
||||
"expiry180": "180 días",
|
||||
"expiry365": "1 año",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Aceptar riesgo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -882,7 +885,7 @@
|
||||
"executable": "Exécutable",
|
||||
"workingDir": "Répertoire de travail",
|
||||
"started": "Commencé",
|
||||
"runningFor": "Courir pour"
|
||||
"runningFor": "En cours d'exécution depuis"
|
||||
},
|
||||
"states": {
|
||||
"running": "En cours d'exécution",
|
||||
@@ -1030,7 +1033,10 @@
|
||||
"startOnBoot": "Démarrer au démarrage",
|
||||
"tags": "balises",
|
||||
"tagsPlaceholder": "Ajouter une balise…",
|
||||
"tagsNone": "Aucune balise"
|
||||
"tagsNone": "Aucune balise",
|
||||
"mountErrors": {
|
||||
"configuredButNotMounted": "Configuré mais non monté"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"header": "Journaux pour {name} (VMID : {vmid})",
|
||||
@@ -1214,7 +1220,15 @@
|
||||
"humanWeekly": "hebdomadaire ({day} {time})",
|
||||
"humanMonthly": "mensuel (jour {day} à {time})",
|
||||
"humanHourly": "Horaire",
|
||||
"weekdays": "['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']"
|
||||
"weekdays": [
|
||||
"Dimanche",
|
||||
"Lundi",
|
||||
"Mardi",
|
||||
"Mercredi",
|
||||
"Jeudi",
|
||||
"Vendredi",
|
||||
"Samedi"
|
||||
]
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron hôte détecté",
|
||||
@@ -1241,6 +1255,35 @@
|
||||
"osStatusUnavailable": "Impossible de déterminer l’état des mises à jour du système 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",
|
||||
@@ -1564,22 +1607,26 @@
|
||||
"excludeFromBadgeHelp": "Ne comptez pas cette application dans le badge de mises à jour globales sur la carte de liste LXC.Utile lorsque vous êtes volontairement épinglé à une version spécifique (exigence de suivi, gel de la compatibilité).N'affecte pas l'état de l'onglet Application ni la notification sortante."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "filtrer les machines virtuelles et les conteneurs par statut",
|
||||
"ariaLabel": "Filtrer les machines virtuelles et les conteneurs",
|
||||
"all": "Tous",
|
||||
"running": "Courir",
|
||||
"running": "En cours d'exécution",
|
||||
"stopped": "Arrêté",
|
||||
"empty": "Aucune machine virtuelle ou conteneur avec le statut \"{status}\""
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "rechercher un nom, un identifiant, une étiquette ou une note…",
|
||||
"searchPlaceholder": "Rechercher par nom, ID, étiquette ou note…",
|
||||
"searchAriaLabel": "Rechercher des machines virtuelles et des conteneurs",
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"updates": "mises à jour",
|
||||
"resultCount": "{shown} sur {total} machines",
|
||||
"noMatches": "Aucune machine virtuelle ou LXC ne correspond à votre recherche.",
|
||||
"noRunning": "Aucune machine virtuelle ou LXC en cours d’exécution.",
|
||||
"noStopped": "Aucune machine virtuelle ou LXC arrêtée.",
|
||||
"noUpdates": "Non LXC avec les mises à jour disponibles."
|
||||
"updates": "Mises à jour",
|
||||
"resultCount": "{shown} sur {total} machines",
|
||||
"noMatches": "Aucune VM ni LXC ne correspond à la recherche.",
|
||||
"noRunning": "Aucune VM ni LXC en cours d'exécution.",
|
||||
"noStopped": "Aucune VM ni LXC arrêtée.",
|
||||
"noUpdates": "Aucun LXC avec des mises à jour disponibles.",
|
||||
"typeSynonyms": {
|
||||
"lxc": "conteneur conteneurs",
|
||||
"qemu": "vm machine virtuelle machines virtuelles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -2714,7 +2761,7 @@
|
||||
"executiveSummary": "Résumé exécutif",
|
||||
"hardeningAssessment": "Évaluation du durcissement",
|
||||
"auditOf": "Vérification de",
|
||||
"running": "courir sur",
|
||||
"running": "exécuté sur",
|
||||
"unknownOs": "système d'exploitation inconnu",
|
||||
"noActionableWarnings": "Aucun avertissement exploitable",
|
||||
"and": "et",
|
||||
@@ -4055,14 +4102,14 @@
|
||||
"jobNameHelpAnd": "et",
|
||||
"jobNameHelpBefore": "Utilisé dans les noms de minuterie et les journaux. Les caractères autorisés incluent également",
|
||||
"jobNameLocked": "Le nom du travail est verrouillé après la création.",
|
||||
"lastRun": "Dernière course",
|
||||
"lastRunLabel": "Dernière course",
|
||||
"lastRun": "Dernière exécution",
|
||||
"lastRunLabel": "Dernière exécution",
|
||||
"liveLogSize": "en direct · {size}",
|
||||
"loadFailed": "Impossible de charger les tâches",
|
||||
"loadingJob": "Chargement du travail...",
|
||||
"manualOneShot": "manuel / one-shot",
|
||||
"manualOneShotDescription": "Sauvegarde ponctuelle : capturée au moment du déclenchement. Ne peut pas être réexécuté ou modifié.",
|
||||
"neverRun": "ne jamais courir",
|
||||
"neverRun": "jamais exécuté",
|
||||
"newScheduledJob": "Nouveau travail planifié",
|
||||
"newScheduledJobDescription": "Créez une tâche de sauvegarde d'hôte récurrente.",
|
||||
"nextRun": "Prochaine exécution",
|
||||
@@ -4193,7 +4240,7 @@
|
||||
"inProgress": "Sauvegarde manuelle en cours",
|
||||
"oneShotDescription": "Les sauvegardes ponctuelles sont conservées sous forme de tâches gelées afin que vous puissiez inspecter leur journal ultérieurement.",
|
||||
"reopenLogTitle": "Rouvrir le journal",
|
||||
"run": "Courir",
|
||||
"run": "Exécuter",
|
||||
"runBackup": "Exécuter une sauvegarde",
|
||||
"runOneShotBackup": "Exécuter une sauvegarde ponctuelle",
|
||||
"title": "Sauvegarde manuelle",
|
||||
@@ -4861,5 +4908,134 @@
|
||||
"customLinkDelete": "Delete",
|
||||
"customLinkSaveError": "Save failed",
|
||||
"customLinkDeleteError": "Delete failed"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit & Report",
|
||||
"loading": "Loading assessment…",
|
||||
"run": "Run assessment",
|
||||
"running": "Assessing…",
|
||||
"neverRun": "This host has not been assessed yet.",
|
||||
"lastRun": "Last assessed on {when}",
|
||||
"stale": "{days} days ago",
|
||||
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"showPassing": "Show passing checks",
|
||||
"hidePassing": "Hide passing checks",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
"accepted": "Accepted risk",
|
||||
"pass": "Passed",
|
||||
"not_applicable": "Not applicable"
|
||||
},
|
||||
"areas": {
|
||||
"all": "All",
|
||||
"system": "System",
|
||||
"storage": "Storage",
|
||||
"network": "Network",
|
||||
"security": "Security",
|
||||
"backup": "Backup",
|
||||
"guests": "Guests",
|
||||
"hardware": "Hardware"
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk"
|
||||
},
|
||||
"errors": {
|
||||
"runFailed": "The assessment could not be started."
|
||||
},
|
||||
"checks": {
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Backup coverage",
|
||||
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
|
||||
"summary": {
|
||||
"noJobs": "No backup job is defined on this node",
|
||||
"covered": "All {total} guests are covered by a backup job",
|
||||
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pending_reboot": {
|
||||
"title": "Restart state",
|
||||
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
|
||||
"summary": {
|
||||
"none": "No restart is pending",
|
||||
"pending": "The host has a pending restart"
|
||||
}
|
||||
},
|
||||
"enterprise_repo_without_subscription": {
|
||||
"title": "Enterprise repository",
|
||||
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
|
||||
"summary": {
|
||||
"notEnabled": "The enterprise repository is not enabled",
|
||||
"subscribed": "The enterprise repository is backed by a subscription",
|
||||
"unsubscribed": "The enterprise repository is enabled without an active subscription"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guests": {
|
||||
"privileged_containers": {
|
||||
"title": "Container privileges",
|
||||
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
|
||||
"summary": {
|
||||
"allUnprivileged": "All {total} containers are unprivileged",
|
||||
"privileged": "{count} of {total} containers run privileged"
|
||||
}
|
||||
},
|
||||
"qemu_without_agent": {
|
||||
"title": "Guest agent on virtual machines",
|
||||
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
|
||||
"summary": {
|
||||
"allHaveAgent": "All {total} virtual machines declare the guest agent",
|
||||
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"host_firewall_enabled": {
|
||||
"title": "Firewall state",
|
||||
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
|
||||
"summary": {
|
||||
"bothEnabled": "The firewall is enabled at datacenter and node level",
|
||||
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
|
||||
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"orphaned_volumes": {
|
||||
"title": "Volume assignment",
|
||||
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
|
||||
"summary": {
|
||||
"none": "No orphaned volumes were found",
|
||||
"found": "{count} volume(s) belong to no existing guest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"profile": "Profilo",
|
||||
"node": "Nodo",
|
||||
"admin": "Ammin",
|
||||
"menu": "Menù di navigazione"
|
||||
"menu": "Menù di navigazione",
|
||||
"audit": "Audit e report"
|
||||
},
|
||||
"status": {
|
||||
"healthy": "Salutare",
|
||||
@@ -72,6 +73,8 @@
|
||||
"networkErrorWhileSaving": "Errore di rete durante il salvataggio"
|
||||
},
|
||||
"overview": {
|
||||
"metricsTimeout": "La richiesta delle metriche ha superato i 30 secondi. ProxMenux riproverà automaticamente.",
|
||||
"metricsCachedWarning": "Impossibile aggiornare le metriche. Sono visualizzati i dati recuperati il {time}.",
|
||||
"loadingTitle": "Caricamento panoramica del sistema...",
|
||||
"loadingDescription": "Recupero dello stato e delle metriche del sistema",
|
||||
"topProcessesCpu": "Visualizza i processi principali per CPU",
|
||||
@@ -327,7 +330,7 @@
|
||||
"statusValues": {
|
||||
"passed": "Superato",
|
||||
"failed": "Fallito",
|
||||
"running": "Corsa",
|
||||
"running": "In esecuzione",
|
||||
"aborted": "Abortito",
|
||||
"unknown": "Sconosciuto"
|
||||
}
|
||||
@@ -882,10 +885,10 @@
|
||||
"executable": "Eseguibile",
|
||||
"workingDir": "Dir. di lavoro",
|
||||
"started": "Iniziato",
|
||||
"runningFor": "Correre per"
|
||||
"runningFor": "In esecuzione da"
|
||||
},
|
||||
"states": {
|
||||
"running": "Corsa",
|
||||
"running": "In esecuzione",
|
||||
"sleeping": "Dormire",
|
||||
"diskWait": "Attendere il disco",
|
||||
"zombie": "Zombie",
|
||||
@@ -905,7 +908,7 @@
|
||||
"totalCpuAllocated": "CPU totale allocata",
|
||||
"totalMemory": "Memoria totale",
|
||||
"totalDisk": "Disco totale",
|
||||
"running": "corsa",
|
||||
"running": "in esecuzione",
|
||||
"stopped": "fermato",
|
||||
"vms": "VM",
|
||||
"used": "Usato",
|
||||
@@ -1030,7 +1033,10 @@
|
||||
"startOnBoot": "inizia all'avvio",
|
||||
"tags": "tag",
|
||||
"tagsPlaceholder": "aggiungi tag…",
|
||||
"tagsNone": "nessun tag"
|
||||
"tagsNone": "nessun tag",
|
||||
"mountErrors": {
|
||||
"configuredButNotMounted": "Configurato ma non montato"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"header": "Registri per {name} (VMID: {vmid})",
|
||||
@@ -1214,7 +1220,15 @@
|
||||
"humanWeekly": "testo tecnico dell'interfaccia utente per una dashboard di gestione Proxmox.Traduzione: settimanale ({day} {time})",
|
||||
"humanMonthly": "testo tecnico dell'interfaccia utente per una dashboard di gestione Proxmox.Traduzione: mensile (giorno {day} alle {time})",
|
||||
"humanHourly": "ogni ora",
|
||||
"weekdays": "['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato']"
|
||||
"weekdays": [
|
||||
"Domenica",
|
||||
"Lunedì",
|
||||
"Martedì",
|
||||
"Mercoledì",
|
||||
"Giovedì",
|
||||
"Venerdì",
|
||||
"Sabato"
|
||||
]
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "rilevato il cron dell'host",
|
||||
@@ -1241,6 +1255,35 @@
|
||||
"osStatusUnavailable": "Impossibile determinare lo stato degli aggiornamenti del sistema operativo.",
|
||||
"installedByHelperPrefix": "Installato da",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperCommandEditHint": "Se modifichi questo comando di avvio, verrà salvato come comando personalizzato.",
|
||||
"customOnlineDescription": "Oppure usa uno script online fornito dallo sviluppatore:",
|
||||
"customOnlineExampleNote": "Sostituisci 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",
|
||||
@@ -1564,22 +1607,26 @@
|
||||
"excludeFromBadgeHelp": "non contare questa app nel badge degli aggiornamenti aggregati sulla scheda dell'elenco LXC.Utile quando sei bloccato di proposito su una versione specifica (requisito del tracker, blocco della compatibilità).Non influisce sullo stato della scheda App o sulla notifica in uscita."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "filtra macchine virtuali e contenitori in base allo stato",
|
||||
"all": "tutto",
|
||||
"running": "Correre",
|
||||
"stopped": "fermato",
|
||||
"empty": "nessuna macchina virtuale o contenitore con stato \"{status}\""
|
||||
"ariaLabel": "Filtra macchine virtuali e container",
|
||||
"all": "Tutti",
|
||||
"running": "In esecuzione",
|
||||
"stopped": "Arrestato",
|
||||
"empty": "Nessuna macchina virtuale o container con stato \"{status}\""
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "cerca nome, ID, tag o nota...",
|
||||
"searchAriaLabel": "cerca macchine virtuali e contenitori",
|
||||
"clearSearch": "cancella la ricerca",
|
||||
"searchPlaceholder": "Cerca per nome, ID, tag o nota…",
|
||||
"searchAriaLabel": "Cerca macchine virtuali e container",
|
||||
"clearSearch": "Cancella ricerca",
|
||||
"updates": "Aggiornamenti",
|
||||
"resultCount": "{shown} di {total} macchine",
|
||||
"noMatches": "nessuna VM o LXC corrisponde alla tua ricerca.",
|
||||
"noRunning": "nessuna VM o LXC in esecuzione.",
|
||||
"noStopped": "nessuna VM o LXC interrotta.",
|
||||
"noUpdates": "No LXC con aggiornamenti disponibili."
|
||||
"noMatches": "Nessuna VM o LXC corrisponde alla ricerca.",
|
||||
"noRunning": "Nessuna VM o LXC in esecuzione.",
|
||||
"noStopped": "Nessuna VM o LXC arrestata.",
|
||||
"noUpdates": "Nessun LXC con aggiornamenti disponibili.",
|
||||
"typeSynonyms": {
|
||||
"lxc": "contenitore contenitori container",
|
||||
"qemu": "vm macchina virtuale macchine virtuali"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -2714,7 +2761,7 @@
|
||||
"executiveSummary": "Sintesi",
|
||||
"hardeningAssessment": "Valutazione dell'indurimento",
|
||||
"auditOf": "Revisione di",
|
||||
"running": "correndo",
|
||||
"running": "in esecuzione su",
|
||||
"unknownOs": "sistema operativo sconosciuto",
|
||||
"noActionableWarnings": "Nessun avviso utilizzabile",
|
||||
"and": "E",
|
||||
@@ -3577,7 +3624,7 @@
|
||||
"reinstallViaPostInstall": "Reinstallare tramite ProxMenux dopo l'installazione: {label}",
|
||||
"noUpdatesAvailable": "Nessun aggiornamento disponibile",
|
||||
"none": "nessuno",
|
||||
"running": "corsa",
|
||||
"running": "in esecuzione",
|
||||
"stopped": "fermato",
|
||||
"unused": "inutilizzato",
|
||||
"unbound": "non vincolato",
|
||||
@@ -3987,7 +4034,7 @@
|
||||
"name": "Nome",
|
||||
"nameLabel": "Nome",
|
||||
"newRecoveryPassphrase": "Nuova passphrase di ripristino",
|
||||
"nextRun": "Prossima corsa",
|
||||
"nextRun": "Prossima esecuzione",
|
||||
"normalizedLabel": "Normalizzato",
|
||||
"onCalendarExpression": "Espressione OnCalendar",
|
||||
"ownerLabel": "Proprietario",
|
||||
@@ -4055,19 +4102,19 @@
|
||||
"jobNameHelpAnd": "E",
|
||||
"jobNameHelpBefore": "Utilizzato nei nomi e nei registri dei timer. Sono inclusi anche i caratteri consentiti",
|
||||
"jobNameLocked": "Il nome del lavoro viene bloccato dopo la creazione.",
|
||||
"lastRun": "Ultima corsa",
|
||||
"lastRunLabel": "Ultima corsa",
|
||||
"lastRun": "Ultima esecuzione",
|
||||
"lastRunLabel": "Ultima esecuzione",
|
||||
"liveLogSize": "dal vivo · {size}",
|
||||
"loadFailed": "Impossibile caricare i lavori",
|
||||
"loadingJob": "Caricamento lavoro...",
|
||||
"manualOneShot": "manuale/one-shot",
|
||||
"manualOneShotDescription": "Backup one-shot: acquisito al momento dell'attivazione. Non può essere rieseguito o modificato.",
|
||||
"neverRun": "non correre mai",
|
||||
"neverRun": "mai eseguito",
|
||||
"newScheduledJob": "Nuovo lavoro programmato",
|
||||
"newScheduledJobDescription": "Creare un processo di backup dell'host ricorrente.",
|
||||
"nextRun": "Prossima corsa",
|
||||
"nextRunLabel": "Prossima corsa",
|
||||
"nextRunTitle": "Prossima corsa programmata",
|
||||
"nextRun": "Prossima esecuzione",
|
||||
"nextRunLabel": "Prossima esecuzione",
|
||||
"nextRunTitle": "Prossima esecuzione pianificata",
|
||||
"noCompatiblePveJob": "Nessun processo di backup PVE compatibile trovato",
|
||||
"noCompatiblePveJobDescriptionAfter": "Primo.",
|
||||
"noCompatiblePveJobDescriptionBefore": "Crea o abilita un processo di backup PVE per",
|
||||
@@ -4193,7 +4240,7 @@
|
||||
"inProgress": "Backup manuale in corso",
|
||||
"oneShotDescription": "I backup one-shot vengono conservati come processi congelati in modo da poterne controllare il registro in un secondo momento.",
|
||||
"reopenLogTitle": "Riapri registro",
|
||||
"run": "Correre",
|
||||
"run": "Esegui",
|
||||
"runBackup": "Esegui il backup",
|
||||
"runOneShotBackup": "Esegui il backup one-shot",
|
||||
"title": "Backup manuale",
|
||||
@@ -4369,7 +4416,7 @@
|
||||
"manual": "manuale",
|
||||
"manualOneShot": "manuale/one-shot",
|
||||
"ok": "OK",
|
||||
"running": "corsa",
|
||||
"running": "in esecuzione",
|
||||
"scheduled": "programmato"
|
||||
},
|
||||
"taskStates": {
|
||||
@@ -4378,7 +4425,7 @@
|
||||
"packing": "imballaggio",
|
||||
"queued": "in coda",
|
||||
"restoring": "ripristino",
|
||||
"running": "corsa"
|
||||
"running": "in esecuzione"
|
||||
},
|
||||
"usb": {
|
||||
"description": "Monta le unità USB in modo che possano essere scelte come destinazione locale o Borg. Le unità che già disponevano di un filesystem possono essere rimontate così come sono; è possibile cancellare e formattare le unità raw (nessuna tabella delle partizioni).",
|
||||
@@ -4861,5 +4908,134 @@
|
||||
"customLinkDelete": "Delete",
|
||||
"customLinkSaveError": "Save failed",
|
||||
"customLinkDeleteError": "Delete failed"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit & Report",
|
||||
"loading": "Loading assessment…",
|
||||
"run": "Run assessment",
|
||||
"running": "Assessing…",
|
||||
"neverRun": "This host has not been assessed yet.",
|
||||
"lastRun": "Last assessed on {when}",
|
||||
"stale": "{days} days ago",
|
||||
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"showPassing": "Show passing checks",
|
||||
"hidePassing": "Hide passing checks",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
"accepted": "Accepted risk",
|
||||
"pass": "Passed",
|
||||
"not_applicable": "Not applicable"
|
||||
},
|
||||
"areas": {
|
||||
"all": "All",
|
||||
"system": "System",
|
||||
"storage": "Storage",
|
||||
"network": "Network",
|
||||
"security": "Security",
|
||||
"backup": "Backup",
|
||||
"guests": "Guests",
|
||||
"hardware": "Hardware"
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk"
|
||||
},
|
||||
"errors": {
|
||||
"runFailed": "The assessment could not be started."
|
||||
},
|
||||
"checks": {
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Backup coverage",
|
||||
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
|
||||
"summary": {
|
||||
"noJobs": "No backup job is defined on this node",
|
||||
"covered": "All {total} guests are covered by a backup job",
|
||||
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pending_reboot": {
|
||||
"title": "Restart state",
|
||||
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
|
||||
"summary": {
|
||||
"none": "No restart is pending",
|
||||
"pending": "The host has a pending restart"
|
||||
}
|
||||
},
|
||||
"enterprise_repo_without_subscription": {
|
||||
"title": "Enterprise repository",
|
||||
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
|
||||
"summary": {
|
||||
"notEnabled": "The enterprise repository is not enabled",
|
||||
"subscribed": "The enterprise repository is backed by a subscription",
|
||||
"unsubscribed": "The enterprise repository is enabled without an active subscription"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guests": {
|
||||
"privileged_containers": {
|
||||
"title": "Container privileges",
|
||||
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
|
||||
"summary": {
|
||||
"allUnprivileged": "All {total} containers are unprivileged",
|
||||
"privileged": "{count} of {total} containers run privileged"
|
||||
}
|
||||
},
|
||||
"qemu_without_agent": {
|
||||
"title": "Guest agent on virtual machines",
|
||||
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
|
||||
"summary": {
|
||||
"allHaveAgent": "All {total} virtual machines declare the guest agent",
|
||||
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"host_firewall_enabled": {
|
||||
"title": "Firewall state",
|
||||
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
|
||||
"summary": {
|
||||
"bothEnabled": "The firewall is enabled at datacenter and node level",
|
||||
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
|
||||
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"orphaned_volumes": {
|
||||
"title": "Volume assignment",
|
||||
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
|
||||
"summary": {
|
||||
"none": "No orphaned volumes were found",
|
||||
"found": "{count} volume(s) belong to no existing guest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"profile": "Perfil",
|
||||
"node": "Nó",
|
||||
"admin": "Administrador",
|
||||
"menu": "Menu de navegação"
|
||||
"menu": "Menu de navegação",
|
||||
"audit": "Auditoria e relatórios"
|
||||
},
|
||||
"status": {
|
||||
"healthy": "Saudável",
|
||||
@@ -72,6 +73,8 @@
|
||||
"networkErrorWhileSaving": "Erro de rede ao salvar"
|
||||
},
|
||||
"overview": {
|
||||
"metricsTimeout": "A consulta de métricas excedeu 30 segundos. O ProxMenux tentará novamente automaticamente.",
|
||||
"metricsCachedWarning": "Não foi possível atualizar as métricas. São apresentados os dados obtidos em {time}.",
|
||||
"loadingTitle": "Carregando visão geral do sistema...",
|
||||
"loadingDescription": "Buscando status e métricas do sistema",
|
||||
"topProcessesCpu": "Veja os principais processos por CPU",
|
||||
@@ -327,7 +330,7 @@
|
||||
"statusValues": {
|
||||
"passed": "Aprovado",
|
||||
"failed": "Fracassado",
|
||||
"running": "Correndo",
|
||||
"running": "Em execução",
|
||||
"aborted": "Abortado",
|
||||
"unknown": "Desconhecido"
|
||||
}
|
||||
@@ -882,10 +885,10 @@
|
||||
"executable": "Executável",
|
||||
"workingDir": "Diretório de trabalho",
|
||||
"started": "Iniciado",
|
||||
"runningFor": "Correndo para"
|
||||
"runningFor": "Em execução há"
|
||||
},
|
||||
"states": {
|
||||
"running": "Correndo",
|
||||
"running": "Em execução",
|
||||
"sleeping": "Dormindo",
|
||||
"diskWait": "Espera de disco",
|
||||
"zombie": "Zumbi",
|
||||
@@ -905,7 +908,7 @@
|
||||
"totalCpuAllocated": "Total de CPU alocada",
|
||||
"totalMemory": "Memória total",
|
||||
"totalDisk": "Disco total",
|
||||
"running": "correndo",
|
||||
"running": "em execução",
|
||||
"stopped": "parou",
|
||||
"vms": "VMs",
|
||||
"used": "Usado",
|
||||
@@ -1030,7 +1033,10 @@
|
||||
"startOnBoot": "Iniciar na inicialização",
|
||||
"tags": "Etiquetas",
|
||||
"tagsPlaceholder": "Adicionar tag…",
|
||||
"tagsNone": "sem tags"
|
||||
"tagsNone": "sem tags",
|
||||
"mountErrors": {
|
||||
"configuredButNotMounted": "Configurado mas não montado"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"header": "Registros para {name} (VMID: {vmid})",
|
||||
@@ -1214,7 +1220,15 @@
|
||||
"humanWeekly": "Semanal ({day} {time})",
|
||||
"humanMonthly": "Mensalmente (dia {day} às {time})",
|
||||
"humanHourly": "de hora em hora",
|
||||
"weekdays": "['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado']"
|
||||
"weekdays": [
|
||||
"Domingo",
|
||||
"Segunda-feira",
|
||||
"Terça-feira",
|
||||
"Quarta-feira",
|
||||
"Quinta-feira",
|
||||
"Sexta-feira",
|
||||
"Sábado"
|
||||
]
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron do host detectado",
|
||||
@@ -1241,6 +1255,35 @@
|
||||
"osStatusUnavailable": "Não foi possível determinar o estado das atualizações do sistema operativo.",
|
||||
"installedByHelperPrefix": "Instalado por",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperCommandEditHint": "Se modificar este comando de arranque, será guardado como comando personalizado.",
|
||||
"customOnlineDescription": "Ou utilize um script online fornecido pelo programador:",
|
||||
"customOnlineExampleNote": "Substitua o URL de exemplo pelo do script oficial da sua aplicação.",
|
||||
"customExamplesHeading": "Exemplos para adaptar:",
|
||||
"updaterChoiceHint": "Escolha e guarde um método de atualização.",
|
||||
"updaterMethodLabel": "Método de atualização",
|
||||
"helperMethod": "Helper-Scripts",
|
||||
"customMethod": "Comando personalizado",
|
||||
"helperMethodHelp": "Sobre Helper-Scripts",
|
||||
"customMethodHelp": "Sobre comandos personalizados",
|
||||
"helperDetectedChoice": "Foi detetado um atualizador Helper-Scripts para esta aplicação.",
|
||||
"helperUnavailableChoice": "O atualizador Helper-Scripts selecionado já não está disponível.",
|
||||
"disableUpdater": "Desativar",
|
||||
"disableUpdaterConfirm": "Desativar o método de atualização de {name}? Os planos e agendamentos manterão as seleções, mas esta aplicação não será executada até configurar novamente um método.",
|
||||
"noUpdaterSelected": "Não selecionou um método de atualização.",
|
||||
"helperMethodDescription": "O ProxMenux executa o atualizador detetado dentro deste LXC e mostra o registo e o resultado. Este é mantido pelo Proxmox VE Helper-Scripts.",
|
||||
"customMethodDescription": "Pode utilizar o atualizador da aplicação, um gestor de pacotes ou um novo binário. O comando é executado dentro deste LXC com privilégios de administrador.",
|
||||
"updaterInstructions": "Consulte as instruções do atualizador.",
|
||||
"helperDocumentation": "Documentação de atualização",
|
||||
"helperSource": "Script da aplicação",
|
||||
"customMethodExample": "Adapte os nomes, os caminhos e o serviço à sua aplicação. Só será executado o comando que guardar.",
|
||||
"helperCommandLabel": "Comando dentro do LXC",
|
||||
"helperCommandFallback": "Se o wget não estiver disponível, o ProxMenux utiliza curl -fsSL para transferir o mesmo script.",
|
||||
"customScriptTitle": "Script de atualização",
|
||||
"customScriptDescription": "Execute um script de atualização da aplicação já existente no LXC.",
|
||||
"customPackageTitle": "Pacote Debian/Ubuntu",
|
||||
"customPackageDescription": "Substitua my-package pelo pacote que pretende atualizar.",
|
||||
"customBinaryTitle": "Binário",
|
||||
"customBinaryDescription": "Primeiro transfira e verifique o binário oficial da versão e arquitetura corretas em /tmp/my-app.new. Este exemplo conserva uma cópia do binário anterior e reinicia o respetivo serviço.",
|
||||
"helperUpdatesRun": "Instalado pelo Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "instalado",
|
||||
"upstreamAvailable": "versão {version} disponível",
|
||||
@@ -1564,22 +1607,26 @@
|
||||
"excludeFromBadgeHelp": "não conte este aplicativo no selo de atualizações agregadas no cartão de lista LXC.Útil quando você está fixado em uma versão específica propositalmente (requisito do rastreador, congelamento de compatibilidade).Não afeta o próprio estado da guia Aplicativo ou a notificação de saída."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Filtre máquinas virtuais e contêineres por status",
|
||||
"ariaLabel": "Filtrar máquinas virtuais e contêineres",
|
||||
"all": "Todos",
|
||||
"running": "Correndo",
|
||||
"running": "Em execução",
|
||||
"stopped": "Parado",
|
||||
"empty": "Nenhuma máquina virtual ou contêiner com status \"{status}\""
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "pesquise nome, ID, tag ou nota…",
|
||||
"searchAriaLabel": "Pesquise máquinas virtuais e contêineres",
|
||||
"clearSearch": "Limpar pesquisa",
|
||||
"searchPlaceholder": "Buscar por nome, ID, tag ou nota…",
|
||||
"searchAriaLabel": "Buscar máquinas virtuais e contêineres",
|
||||
"clearSearch": "Limpar busca",
|
||||
"updates": "Atualizações",
|
||||
"resultCount": "{shown} de {total} máquinas",
|
||||
"noMatches": "Nenhuma VM ou LXC corresponde à sua pesquisa.",
|
||||
"noRunning": "Não há VMs ou LXCs em execução.",
|
||||
"noStopped": "Não há VMs ou LXCs parados.",
|
||||
"noUpdates": "Não LXC com atualizações disponíveis."
|
||||
"noMatches": "Nenhuma VM ou LXC corresponde à busca.",
|
||||
"noRunning": "Nenhuma VM ou LXC em execução.",
|
||||
"noStopped": "Nenhuma VM ou LXC parada.",
|
||||
"noUpdates": "Nenhum LXC com atualizações disponíveis.",
|
||||
"typeSynonyms": {
|
||||
"lxc": "contêiner contêineres contentor contentores",
|
||||
"qemu": "vm máquina virtual máquinas virtuais"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -2714,7 +2761,7 @@
|
||||
"executiveSummary": "Sumário executivo",
|
||||
"hardeningAssessment": "Avaliação de endurecimento",
|
||||
"auditOf": "Auditoria de",
|
||||
"running": "correndo",
|
||||
"running": "em execução em",
|
||||
"unknownOs": "SO desconhecido",
|
||||
"noActionableWarnings": "Nenhum aviso acionável",
|
||||
"and": "e",
|
||||
@@ -3577,7 +3624,7 @@
|
||||
"reinstallViaPostInstall": "Reinstale via ProxMenux pós-instalação: {label}",
|
||||
"noUpdatesAvailable": "Nenhuma atualização disponível",
|
||||
"none": "nenhum",
|
||||
"running": "correndo",
|
||||
"running": "em execução",
|
||||
"stopped": "parou",
|
||||
"unused": "não utilizado",
|
||||
"unbound": "não vinculado",
|
||||
@@ -4193,7 +4240,7 @@
|
||||
"inProgress": "Backup manual em andamento",
|
||||
"oneShotDescription": "Os backups únicos são mantidos como trabalhos congelados para que você possa inspecionar seus logs mais tarde.",
|
||||
"reopenLogTitle": "Reabrir registro",
|
||||
"run": "Correr",
|
||||
"run": "Executar",
|
||||
"runBackup": "Executar backup",
|
||||
"runOneShotBackup": "Execute o backup único",
|
||||
"title": "Backup manual",
|
||||
@@ -4369,7 +4416,7 @@
|
||||
"manual": "manual",
|
||||
"manualOneShot": "manual / único",
|
||||
"ok": "OK",
|
||||
"running": "correndo",
|
||||
"running": "em execução",
|
||||
"scheduled": "agendado"
|
||||
},
|
||||
"taskStates": {
|
||||
@@ -4378,7 +4425,7 @@
|
||||
"packing": "embalagem",
|
||||
"queued": "na fila",
|
||||
"restoring": "restaurando",
|
||||
"running": "correndo"
|
||||
"running": "em execução"
|
||||
},
|
||||
"usb": {
|
||||
"description": "Monte unidades USB para que possam ser escolhidas como alvo local ou Borg. Unidades que já possuem um sistema de arquivos podem ser remontadas como estão; unidades brutas (sem tabela de partição) podem ser apagadas e formatadas para",
|
||||
@@ -4861,5 +4908,134 @@
|
||||
"customLinkDelete": "Delete",
|
||||
"customLinkSaveError": "Save failed",
|
||||
"customLinkDeleteError": "Delete failed"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit & Report",
|
||||
"loading": "Loading assessment…",
|
||||
"run": "Run assessment",
|
||||
"running": "Assessing…",
|
||||
"neverRun": "This host has not been assessed yet.",
|
||||
"lastRun": "Last assessed on {when}",
|
||||
"stale": "{days} days ago",
|
||||
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"showPassing": "Show passing checks",
|
||||
"hidePassing": "Hide passing checks",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
"accepted": "Accepted risk",
|
||||
"pass": "Passed",
|
||||
"not_applicable": "Not applicable"
|
||||
},
|
||||
"areas": {
|
||||
"all": "All",
|
||||
"system": "System",
|
||||
"storage": "Storage",
|
||||
"network": "Network",
|
||||
"security": "Security",
|
||||
"backup": "Backup",
|
||||
"guests": "Guests",
|
||||
"hardware": "Hardware"
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk"
|
||||
},
|
||||
"errors": {
|
||||
"runFailed": "The assessment could not be started."
|
||||
},
|
||||
"checks": {
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Backup coverage",
|
||||
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
|
||||
"summary": {
|
||||
"noJobs": "No backup job is defined on this node",
|
||||
"covered": "All {total} guests are covered by a backup job",
|
||||
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pending_reboot": {
|
||||
"title": "Restart state",
|
||||
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
|
||||
"summary": {
|
||||
"none": "No restart is pending",
|
||||
"pending": "The host has a pending restart"
|
||||
}
|
||||
},
|
||||
"enterprise_repo_without_subscription": {
|
||||
"title": "Enterprise repository",
|
||||
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
|
||||
"summary": {
|
||||
"notEnabled": "The enterprise repository is not enabled",
|
||||
"subscribed": "The enterprise repository is backed by a subscription",
|
||||
"unsubscribed": "The enterprise repository is enabled without an active subscription"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guests": {
|
||||
"privileged_containers": {
|
||||
"title": "Container privileges",
|
||||
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
|
||||
"summary": {
|
||||
"allUnprivileged": "All {total} containers are unprivileged",
|
||||
"privileged": "{count} of {total} containers run privileged"
|
||||
}
|
||||
},
|
||||
"qemu_without_agent": {
|
||||
"title": "Guest agent on virtual machines",
|
||||
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
|
||||
"summary": {
|
||||
"allHaveAgent": "All {total} virtual machines declare the guest agent",
|
||||
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"host_firewall_enabled": {
|
||||
"title": "Firewall state",
|
||||
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
|
||||
"summary": {
|
||||
"bothEnabled": "The firewall is enabled at datacenter and node level",
|
||||
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
|
||||
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"orphaned_volumes": {
|
||||
"title": "Volume assignment",
|
||||
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
|
||||
"summary": {
|
||||
"none": "No orphaned volumes were found",
|
||||
"found": "{count} volume(s) belong to no existing guest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"profile": "Profil",
|
||||
"node": "Server",
|
||||
"admin": "Správa",
|
||||
"menu": "Navigačné menu"
|
||||
"menu": "Navigačné menu",
|
||||
"audit": "Audit a správy"
|
||||
},
|
||||
"status": {
|
||||
"healthy": "V poriadku",
|
||||
@@ -71,6 +72,8 @@
|
||||
"networkErrorWhileSaving": "Chyba siete pri ukladaní"
|
||||
},
|
||||
"overview": {
|
||||
"metricsTimeout": "Načítanie metrík trvalo dlhšie ako 30 sekúnd. ProxMenux to automaticky skúsi znova.",
|
||||
"metricsCachedWarning": "Metriky sa nepodarilo obnoviť. Zobrazujú sa údaje načítané {time}.",
|
||||
"loadingTitle": "Načítava sa prehľad systému...",
|
||||
"loadingDescription": "Zisťujem stav servera a základné údaje",
|
||||
"topProcessesCpu": "Zobraziť procesy s najvyšším využitím CPU",
|
||||
@@ -932,7 +935,11 @@
|
||||
"noMatches": "Žiadne VM ani LXC nezodpovedajú hľadaniu.",
|
||||
"noRunning": "Nie sú tu žiadne spustené VM ani LXC.",
|
||||
"noStopped": "Nie sú tu žiadne vypnuté VM ani LXC.",
|
||||
"noUpdates": "Žiadne LXC nemá dostupné aktualizácie."
|
||||
"noUpdates": "Žiadne LXC nemá dostupné aktualizácie.",
|
||||
"typeSynonyms": {
|
||||
"lxc": "kontajner kontajnery",
|
||||
"qemu": "vm virtuálny stroj virtuálne stroje"
|
||||
}
|
||||
},
|
||||
"uptime": "Beží: {uptime}",
|
||||
"cpuUsage": "Využitie CPU",
|
||||
@@ -1044,6 +1051,9 @@
|
||||
"stopped": "vypnuté",
|
||||
"mounted": "pripojené"
|
||||
},
|
||||
"mountErrors": {
|
||||
"configuredButNotMounted": "Nastavené, ale nepripojené"
|
||||
},
|
||||
"startOnBoot": "Spúšťať pri štarte",
|
||||
"tags": "Značky",
|
||||
"tagsPlaceholder": "Pridať značku…",
|
||||
@@ -1231,7 +1241,15 @@
|
||||
"humanWeekly": "Týždenne ({day} o {time})",
|
||||
"humanMonthly": "Mesačne ({day}. deň o {time})",
|
||||
"humanHourly": "Každú hodinu",
|
||||
"weekdays": ["nedeľa", "pondelok", "utorok", "streda", "štvrtok", "piatok", "sobota"]
|
||||
"weekdays": [
|
||||
"nedeľa",
|
||||
"pondelok",
|
||||
"utorok",
|
||||
"streda",
|
||||
"štvrtok",
|
||||
"piatok",
|
||||
"sobota"
|
||||
]
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "zistený cron na serveri",
|
||||
@@ -1258,6 +1276,35 @@
|
||||
"osStatusUnavailable": "Stav aktualizácií operačného systému sa nepodarilo zistiť.",
|
||||
"installedByHelperPrefix": "Nainštalované cez",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperCommandEditHint": "Ak upravíte tento spúšťací príkaz, uloží sa ako vlastný príkaz.",
|
||||
"customOnlineDescription": "Alebo použite online skript poskytnutý vývojárom:",
|
||||
"customOnlineExampleNote": "Nahraďte vzorovú URL adresu adresou oficiálneho skriptu vašej aplikácie.",
|
||||
"customExamplesHeading": "Príklady na prispôsobenie:",
|
||||
"updaterChoiceHint": "Vyberte a uložte spôsob aktualizácie.",
|
||||
"updaterMethodLabel": "Spôsob aktualizácie",
|
||||
"helperMethod": "Helper-Scripts",
|
||||
"customMethod": "Vlastný príkaz",
|
||||
"helperMethodHelp": "O Helper-Scripts",
|
||||
"customMethodHelp": "O vlastných príkazoch",
|
||||
"helperDetectedChoice": "Pre túto aplikáciu bol zistený aktualizátor Helper-Scripts.",
|
||||
"helperUnavailableChoice": "Vybraný aktualizátor Helper-Scripts už nie je dostupný.",
|
||||
"disableUpdater": "Deaktivovať",
|
||||
"disableUpdaterConfirm": "Deaktivovať spôsob aktualizácie pre {name}? Uložené plány a naplánované úlohy si zachovajú svoj výber, ale táto aplikácia sa nespustí, kým znova nenastavíte spôsob aktualizácie.",
|
||||
"noUpdaterSelected": "Nevybrali ste spôsob aktualizácie.",
|
||||
"helperMethodDescription": "ProxMenux spustí zistený aktualizátor v tomto LXC a zobrazí jeho protokol a výsledok. Aktualizátor spravuje Proxmox VE Helper-Scripts.",
|
||||
"customMethodDescription": "Môžete použiť aktualizátor aplikácie, správcu balíkov alebo nový binárny súbor. Príkaz sa spustí v tomto LXC s oprávneniami správcu.",
|
||||
"updaterInstructions": "Prečítajte si pokyny aktualizátora.",
|
||||
"helperDocumentation": "Dokumentácia aktualizácií",
|
||||
"helperSource": "Skript aplikácie",
|
||||
"customMethodExample": "Prispôsobte názvy, cesty a službu svojej aplikácii. Spustí sa iba príkaz, ktorý uložíte.",
|
||||
"helperCommandLabel": "Príkaz v LXC",
|
||||
"helperCommandFallback": "Ak wget nie je dostupný, ProxMenux použije curl -fsSL na stiahnutie rovnakého skriptu.",
|
||||
"customScriptTitle": "Aktualizačný skript",
|
||||
"customScriptDescription": "Spustite aktualizačný skript aplikácie, ktorý už existuje v LXC.",
|
||||
"customPackageTitle": "Balík Debian/Ubuntu",
|
||||
"customPackageDescription": "Nahraďte my-package balíkom, ktorý chcete aktualizovať.",
|
||||
"customBinaryTitle": "Binárny súbor",
|
||||
"customBinaryDescription": "Najprv stiahnite a overte oficiálny binárny súbor správnej verzie a architektúry v /tmp/my-app.new. Tento príklad zachová kópiu predchádzajúceho binárneho súboru a reštartuje príslušnú službu.",
|
||||
"helperUpdatesRun": "Inštalované pomocou Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "nainštalovaná verzia",
|
||||
"upstreamAvailable": "dostupná verzia {version}",
|
||||
@@ -4927,5 +4974,134 @@
|
||||
"customLinkDelete": "Odstrániť",
|
||||
"customLinkSaveError": "Odkaz sa nepodarilo uložiť",
|
||||
"customLinkDeleteError": "Odkaz sa nepodarilo odstrániť"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit & Report",
|
||||
"loading": "Loading assessment…",
|
||||
"run": "Run assessment",
|
||||
"running": "Assessing…",
|
||||
"neverRun": "This host has not been assessed yet.",
|
||||
"lastRun": "Last assessed on {when}",
|
||||
"stale": "{days} days ago",
|
||||
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"showPassing": "Show passing checks",
|
||||
"hidePassing": "Hide passing checks",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
"accepted": "Accepted risk",
|
||||
"pass": "Passed",
|
||||
"not_applicable": "Not applicable"
|
||||
},
|
||||
"areas": {
|
||||
"all": "All",
|
||||
"system": "System",
|
||||
"storage": "Storage",
|
||||
"network": "Network",
|
||||
"security": "Security",
|
||||
"backup": "Backup",
|
||||
"guests": "Guests",
|
||||
"hardware": "Hardware"
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk"
|
||||
},
|
||||
"errors": {
|
||||
"runFailed": "The assessment could not be started."
|
||||
},
|
||||
"checks": {
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Backup coverage",
|
||||
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
|
||||
"summary": {
|
||||
"noJobs": "No backup job is defined on this node",
|
||||
"covered": "All {total} guests are covered by a backup job",
|
||||
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pending_reboot": {
|
||||
"title": "Restart state",
|
||||
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
|
||||
"summary": {
|
||||
"none": "No restart is pending",
|
||||
"pending": "The host has a pending restart"
|
||||
}
|
||||
},
|
||||
"enterprise_repo_without_subscription": {
|
||||
"title": "Enterprise repository",
|
||||
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
|
||||
"summary": {
|
||||
"notEnabled": "The enterprise repository is not enabled",
|
||||
"subscribed": "The enterprise repository is backed by a subscription",
|
||||
"unsubscribed": "The enterprise repository is enabled without an active subscription"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guests": {
|
||||
"privileged_containers": {
|
||||
"title": "Container privileges",
|
||||
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
|
||||
"summary": {
|
||||
"allUnprivileged": "All {total} containers are unprivileged",
|
||||
"privileged": "{count} of {total} containers run privileged"
|
||||
}
|
||||
},
|
||||
"qemu_without_agent": {
|
||||
"title": "Guest agent on virtual machines",
|
||||
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
|
||||
"summary": {
|
||||
"allHaveAgent": "All {total} virtual machines declare the guest agent",
|
||||
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"host_firewall_enabled": {
|
||||
"title": "Firewall state",
|
||||
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
|
||||
"summary": {
|
||||
"bothEnabled": "The firewall is enabled at datacenter and node level",
|
||||
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
|
||||
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"orphaned_volumes": {
|
||||
"title": "Volume assignment",
|
||||
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
|
||||
"summary": {
|
||||
"none": "No orphaned volumes were found",
|
||||
"found": "{count} volume(s) belong to no existing guest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"profile": "Profil",
|
||||
"node": "Nod",
|
||||
"admin": "Administration",
|
||||
"menu": "Navigationsmeny"
|
||||
"menu": "Navigationsmeny",
|
||||
"audit": "Granskning och rapporter"
|
||||
},
|
||||
"status": {
|
||||
"healthy": "Hälsosam",
|
||||
@@ -72,6 +73,8 @@
|
||||
"networkErrorWhileSaving": "Nätverksfel vid lagring"
|
||||
},
|
||||
"overview": {
|
||||
"metricsTimeout": "Mätvärdesfrågan tog längre än 30 sekunder. ProxMenux försöker igen automatiskt.",
|
||||
"metricsCachedWarning": "Mätvärdena kunde inte uppdateras. Data hämtade {time} visas.",
|
||||
"loadingTitle": "Laddar systemöversikt...",
|
||||
"loadingDescription": "Hämtar systemstatus och mätvärden",
|
||||
"topProcessesCpu": "Visa toppprocesser efter CPU",
|
||||
@@ -1030,7 +1033,10 @@
|
||||
"startOnBoot": "Börja vid start",
|
||||
"tags": "Taggar",
|
||||
"tagsPlaceholder": "Lägg till tagg...",
|
||||
"tagsNone": "Inga taggar"
|
||||
"tagsNone": "Inga taggar",
|
||||
"mountErrors": {
|
||||
"configuredButNotMounted": "Konfigurerad men inte monterad"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"header": "Loggar för {name} (VMID: {vmid})",
|
||||
@@ -1214,7 +1220,15 @@
|
||||
"humanWeekly": "Varje vecka ({day} {time})",
|
||||
"humanMonthly": "Varje månad (dag {day} kl. {time})",
|
||||
"humanHourly": "Varje timme",
|
||||
"weekdays": "['söndag', 'måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag']"
|
||||
"weekdays": [
|
||||
"söndag",
|
||||
"måndag",
|
||||
"tisdag",
|
||||
"onsdag",
|
||||
"torsdag",
|
||||
"fredag",
|
||||
"lördag"
|
||||
]
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "host cron upptäckt",
|
||||
@@ -1241,6 +1255,35 @@
|
||||
"osStatusUnavailable": "Statusen för operativsystemets uppdateringar kunde inte fastställas.",
|
||||
"installedByHelperPrefix": "Installerad av",
|
||||
"helperScriptsName": "Proxmox VE Helper-Scripts",
|
||||
"helperCommandEditHint": "Om du ändrar det här startkommandot sparas det som ett eget kommando.",
|
||||
"customOnlineDescription": "Eller använd ett onlineskript från utvecklaren:",
|
||||
"customOnlineExampleNote": "Ersätt exempeladressen med adressen till appens officiella skript.",
|
||||
"customExamplesHeading": "Exempel att anpassa:",
|
||||
"updaterChoiceHint": "Välj och spara en uppdateringsmetod.",
|
||||
"updaterMethodLabel": "Uppdateringsmetod",
|
||||
"helperMethod": "Helper-Scripts",
|
||||
"customMethod": "Eget kommando",
|
||||
"helperMethodHelp": "Om Helper-Scripts",
|
||||
"customMethodHelp": "Om egna kommandon",
|
||||
"helperDetectedChoice": "En Helper-Scripts-uppdaterare har identifierats för den här appen.",
|
||||
"helperUnavailableChoice": "Den valda Helper-Scripts-uppdateraren är inte längre tillgänglig.",
|
||||
"disableUpdater": "Inaktivera",
|
||||
"disableUpdaterConfirm": "Inaktivera uppdateringsmetoden för {name}? Sparade planer och scheman behåller sina val, men den här appen körs inte förrän en metod har konfigurerats igen.",
|
||||
"noUpdaterSelected": "Du har inte valt någon uppdateringsmetod.",
|
||||
"helperMethodDescription": "ProxMenux kör den identifierade uppdateraren i denna LXC och visar dess logg och resultat. Uppdateraren underhålls av Proxmox VE Helper-Scripts.",
|
||||
"customMethodDescription": "Du kan använda appens egen uppdaterare, en pakethanterare eller en ny binärfil. Kommandot körs i denna LXC med administratörsbehörighet.",
|
||||
"updaterInstructions": "Läs uppdaterarens anvisningar.",
|
||||
"helperDocumentation": "Uppdateringsdokumentation",
|
||||
"helperSource": "Appens skript",
|
||||
"customMethodExample": "Anpassa namn, sökvägar och tjänst till din app. Endast kommandot du sparar kommer att köras.",
|
||||
"helperCommandLabel": "Kommando i LXC",
|
||||
"helperCommandFallback": "Om wget inte är tillgängligt använder ProxMenux curl -fsSL för att hämta samma skript.",
|
||||
"customScriptTitle": "Uppdateringsskript",
|
||||
"customScriptDescription": "Kör ett uppdateringsskript för appen som redan finns i LXC.",
|
||||
"customPackageTitle": "Debian/Ubuntu-paket",
|
||||
"customPackageDescription": "Ersätt my-package med paketet du vill uppdatera.",
|
||||
"customBinaryTitle": "Binärfil",
|
||||
"customBinaryDescription": "Hämta och verifiera först den officiella binärfilen för rätt version och arkitektur på /tmp/my-app.new. Det här exemplet sparar en kopia av den tidigare binärfilen och startar om dess tjänst.",
|
||||
"helperUpdatesRun": "Installerad av Proxmox Helper-Scripts.",
|
||||
"installedPrefix": "installerat",
|
||||
"upstreamAvailable": "version {version} tillgänglig",
|
||||
@@ -1564,22 +1607,26 @@
|
||||
"excludeFromBadgeHelp": "Räkna inte den här appen i det samlade uppdateringsmärket på LXC-listkortet.Användbart när du är fäst till en specifik version med avsikt (spårningskrav, frysning av kompatibilitet).Påverkar inte appflikens eget tillstånd eller det utgående meddelandet."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Filtrera virtuella maskiner och behållare efter status",
|
||||
"ariaLabel": "Filtrera virtuella maskiner och containrar",
|
||||
"all": "Alla",
|
||||
"running": "Löpning",
|
||||
"running": "Körs",
|
||||
"stopped": "Stoppad",
|
||||
"empty": "Inga virtuella maskiner eller behållare med status \"{status}\""
|
||||
"empty": "Inga virtuella maskiner eller containrar med status \"{status}\""
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "Sök efter namn, ID, tagg eller anteckning...",
|
||||
"searchAriaLabel": "Sök i virtuella maskiner och behållare",
|
||||
"searchPlaceholder": "Sök på namn, ID, tagg eller anteckning…",
|
||||
"searchAriaLabel": "Sök virtuella maskiner och containrar",
|
||||
"clearSearch": "Rensa sökning",
|
||||
"updates": "Uppdateringar",
|
||||
"resultCount": "{shown} av {total} maskiner",
|
||||
"noMatches": "Inga virtuella datorer eller LXC:er matchar din sökning.",
|
||||
"noRunning": "Inga virtuella datorer eller LXC:er som körs.",
|
||||
"noStopped": "Inga stoppade virtuella datorer eller LXC:er.",
|
||||
"noUpdates": "Inga LXC med tillgängliga uppdateringar."
|
||||
"noMatches": "Inga VM:ar eller LXC:er matchar sökningen.",
|
||||
"noRunning": "Inga VM:ar eller LXC:er körs.",
|
||||
"noStopped": "Inga stoppade VM:ar eller LXC:er.",
|
||||
"noUpdates": "Inga LXC:er har tillgängliga uppdateringar.",
|
||||
"typeSynonyms": {
|
||||
"lxc": "container containrar",
|
||||
"qemu": "vm virtuell maskin virtuella maskiner"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -4063,7 +4110,7 @@
|
||||
"loadingJob": "Laddar jobb...",
|
||||
"manualOneShot": "manuell / one-shot",
|
||||
"manualOneShotDescription": "Säkerhetskopiering i ett skott — fångad vid tidpunkten för utlösningen. Kan inte köras om eller redigeras.",
|
||||
"neverRun": "aldrig springa",
|
||||
"neverRun": "aldrig körd",
|
||||
"newScheduledJob": "Nytt schemalagt jobb",
|
||||
"newScheduledJobDescription": "Skapa ett återkommande värdbackupjobb.",
|
||||
"nextRun": "Nästa körning",
|
||||
@@ -4862,5 +4909,134 @@
|
||||
"customLinkDelete": "Delete",
|
||||
"customLinkSaveError": "Save failed",
|
||||
"customLinkDeleteError": "Delete failed"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit & Report",
|
||||
"loading": "Loading assessment…",
|
||||
"run": "Run assessment",
|
||||
"running": "Assessing…",
|
||||
"neverRun": "This host has not been assessed yet.",
|
||||
"lastRun": "Last assessed on {when}",
|
||||
"stale": "{days} days ago",
|
||||
"readOnlyNotice": "The assessment only reads the host. It makes no changes.",
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"showPassing": "Show passing checks",
|
||||
"hidePassing": "Hide passing checks",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
"accepted": "Accepted risk",
|
||||
"pass": "Passed",
|
||||
"not_applicable": "Not applicable"
|
||||
},
|
||||
"areas": {
|
||||
"all": "All",
|
||||
"system": "System",
|
||||
"storage": "Storage",
|
||||
"network": "Network",
|
||||
"security": "Security",
|
||||
"backup": "Backup",
|
||||
"guests": "Guests",
|
||||
"hardware": "Hardware"
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk"
|
||||
},
|
||||
"errors": {
|
||||
"runFailed": "The assessment could not be started."
|
||||
},
|
||||
"checks": {
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Backup coverage",
|
||||
"rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.",
|
||||
"summary": {
|
||||
"noJobs": "No backup job is defined on this node",
|
||||
"covered": "All {total} guests are covered by a backup job",
|
||||
"uncovered": "{count} of {total} guests are not covered by any enabled backup job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pending_reboot": {
|
||||
"title": "Restart state",
|
||||
"rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.",
|
||||
"summary": {
|
||||
"none": "No restart is pending",
|
||||
"pending": "The host has a pending restart"
|
||||
}
|
||||
},
|
||||
"enterprise_repo_without_subscription": {
|
||||
"title": "Enterprise repository",
|
||||
"rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.",
|
||||
"summary": {
|
||||
"notEnabled": "The enterprise repository is not enabled",
|
||||
"subscribed": "The enterprise repository is backed by a subscription",
|
||||
"unsubscribed": "The enterprise repository is enabled without an active subscription"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guests": {
|
||||
"privileged_containers": {
|
||||
"title": "Container privileges",
|
||||
"rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.",
|
||||
"summary": {
|
||||
"allUnprivileged": "All {total} containers are unprivileged",
|
||||
"privileged": "{count} of {total} containers run privileged"
|
||||
}
|
||||
},
|
||||
"qemu_without_agent": {
|
||||
"title": "Guest agent on virtual machines",
|
||||
"rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.",
|
||||
"summary": {
|
||||
"allHaveAgent": "All {total} virtual machines declare the guest agent",
|
||||
"missingAgent": "{count} of {total} virtual machines do not declare the guest agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"host_firewall_enabled": {
|
||||
"title": "Firewall state",
|
||||
"rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.",
|
||||
"summary": {
|
||||
"bothEnabled": "The firewall is enabled at datacenter and node level",
|
||||
"datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied",
|
||||
"nodeOff": "The firewall is enabled at datacenter level but not on this node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"orphaned_volumes": {
|
||||
"title": "Volume assignment",
|
||||
"rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.",
|
||||
"summary": {
|
||||
"none": "No orphaned volumes were found",
|
||||
"found": "{count} volume(s) belong to no existing guest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+198
-357
@@ -92,6 +92,7 @@ from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||
from flask_security_routes import security_bp # noqa: E402
|
||||
from flask_notification_routes import notification_bp # noqa: E402
|
||||
from flask_oci_routes import oci_bp # noqa: E402
|
||||
from flask_audit_routes import audit_bp # noqa: E402
|
||||
from notification_manager import notification_manager # noqa: E402
|
||||
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
|
||||
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
|
||||
@@ -227,6 +228,7 @@ app.register_blueprint(proxmenux_bp)
|
||||
app.register_blueprint(security_bp)
|
||||
app.register_blueprint(notification_bp)
|
||||
app.register_blueprint(oci_bp)
|
||||
app.register_blueprint(audit_bp)
|
||||
|
||||
# Initialize terminal / WebSocket routes
|
||||
init_terminal_routes(app)
|
||||
@@ -1697,7 +1699,7 @@ _VM_DISK_REFRESH_WORKERS = 6 # parallelism cap for the fsinfo pass
|
||||
# state must be probed rather than inferred from an API response.
|
||||
_vm_details_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_backups_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_apps_cache: dict = {} # vmid -> (ts, payload)
|
||||
# Registered apps use lxc_apps' shared, file-versioned snapshot cache.
|
||||
_vm_app_suggestions_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_schedule_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
|
||||
@@ -1735,7 +1737,6 @@ _lxc_ip_cache: dict = {}
|
||||
_VM_CACHE_INDEFINITE = 315_360_000 # 10 years — effectively infinite
|
||||
_VM_DETAILS_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_BACKUPS_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_APPS_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_APP_SUGGESTIONS_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_SCHEDULE_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_MOUNTS_TTL = _VM_CACHE_INDEFINITE
|
||||
@@ -1817,7 +1818,7 @@ def _vm_cache_invalidate(vmid: int, *caches) -> None:
|
||||
affect any of them (e.g. control start/stop flips status, which
|
||||
lives in the details payload)."""
|
||||
targets = caches or (
|
||||
_vm_details_cache, _vm_backups_cache, _vm_apps_cache,
|
||||
_vm_details_cache, _vm_backups_cache,
|
||||
_vm_app_suggestions_cache, _vm_schedule_cache, _vm_mounts_cache,
|
||||
)
|
||||
with _vm_modal_cache_lock:
|
||||
@@ -1906,7 +1907,6 @@ def _refresh_started_guest(vmid: int, vm_type: str) -> None:
|
||||
|
||||
sidecar = lxc_apps.check_all(vmid, force=True)
|
||||
sidecar = sidecar or {'vmid': vmid, 'apps': []}
|
||||
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
|
||||
|
||||
docker_registered = any(
|
||||
isinstance(item, dict) and item.get('helper_slug') == 'docker'
|
||||
@@ -11891,51 +11891,69 @@ def api_vm_metrics(vmid):
|
||||
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# Per-process cache for the RRD payload of /api/node/metrics. Two unrelated
|
||||
# dashboard components (`network-traffic-chart` for the network panel and
|
||||
# `node-metrics-charts` for the CPU/memory panel) mount in parallel on the
|
||||
# Overview page and each fires this endpoint independently with the same
|
||||
# `?timeframe=` argument. The underlying `pvesh get rrddata` call takes
|
||||
# ~1 second; without a cache, the second fetch blocks behind the first
|
||||
# (especially under gevent), occasionally surfacing as a transient 502
|
||||
# while gevent is single-threaded for blocking calls. RRD data is updated
|
||||
# on a per-minute cadence by PVE, so a 10-second cache is safe and the
|
||||
# UI experience is materially better.
|
||||
# Shared RRD snapshots and single-flight locks, one per supported timeframe.
|
||||
_NODE_METRICS_TTL = 120.0
|
||||
_NODE_METRICS_TIMEOUT = 30.0
|
||||
_NODE_METRICS_RETRY_DELAY = 30.0
|
||||
_NODE_METRICS_CACHE = {}
|
||||
# TTL is 120 s because the prewarmer only refreshes the `hour`
|
||||
# timeframe (the Overview's default) every 90 s. The other four
|
||||
# timeframes get their first fetch lazily when the user picks them —
|
||||
# they then live in cache for 120 s, which covers back-and-forth
|
||||
# switching without paying pvesh cost. Pre-warming every timeframe
|
||||
# on a fast cadence (as the first version did) burned ~30 % of a
|
||||
# core continuously scanning data nobody was looking at.
|
||||
_NODE_METRICS_TTL = 120.0 # seconds
|
||||
_NODE_METRICS_FAILURES = {}
|
||||
_NODE_METRICS_LOCKS = {
|
||||
timeframe: threading.Lock()
|
||||
for timeframe in ('hour', 'day', 'week', 'month', 'year')
|
||||
}
|
||||
|
||||
|
||||
def _node_metrics_cache_get(timeframe):
|
||||
entry = _NODE_METRICS_CACHE.get(timeframe)
|
||||
if not entry:
|
||||
return None
|
||||
if time.monotonic() - entry['ts'] > _NODE_METRICS_TTL:
|
||||
return None
|
||||
return entry['payload']
|
||||
class _NodeMetricsError(Exception):
|
||||
def __init__(self, payload):
|
||||
super().__init__(payload['error'])
|
||||
self.payload = payload
|
||||
|
||||
|
||||
def _node_metrics_cache_set(timeframe, payload):
|
||||
_NODE_METRICS_CACHE[timeframe] = {'payload': payload, 'ts': time.monotonic()}
|
||||
def _node_metrics_fallback(timeframe, error):
|
||||
cached = _NODE_METRICS_CACHE.get(timeframe)
|
||||
if cached is not None:
|
||||
return {**cached['payload'], 'cache_status': 'stale', 'refresh_error': error}
|
||||
raise _NodeMetricsError(error)
|
||||
|
||||
|
||||
def _compute_node_metrics_payload(timeframe: str) -> dict | None:
|
||||
"""Do the actual pvesh-backed RRD fetch + massaging that
|
||||
`api_node_metrics` used to do inline. Returns the payload dict on
|
||||
success (and populates the cache), None on any failure.
|
||||
Extracted so the background prewarmer can call it without going
|
||||
through HTTP + `@require_auth` — same code path as the handler,
|
||||
zero duplication of the massaging logic."""
|
||||
def _get_node_metrics_payload(timeframe, max_age=_NODE_METRICS_TTL):
|
||||
lock = _NODE_METRICS_LOCKS[timeframe]
|
||||
if not lock.acquire(timeout=_NODE_METRICS_TIMEOUT + 5):
|
||||
return _node_metrics_fallback(timeframe, {
|
||||
'error': 'A metrics query is still in progress', 'code': 'metrics_busy',
|
||||
})
|
||||
try:
|
||||
now = time.monotonic()
|
||||
cached = _NODE_METRICS_CACHE.get(timeframe)
|
||||
failure = _NODE_METRICS_FAILURES.get(timeframe)
|
||||
if failure is not None and now - failure['ts'] < _NODE_METRICS_RETRY_DELAY:
|
||||
return _node_metrics_fallback(timeframe, failure['error'])
|
||||
if cached is not None and now - cached['ts'] < max_age:
|
||||
return cached['payload']
|
||||
try:
|
||||
payload = _compute_node_metrics_payload(timeframe)
|
||||
except Exception as exc:
|
||||
error = exc.payload if isinstance(exc, _NodeMetricsError) else {
|
||||
'error': 'Unable to load Proxmox metrics', 'code': 'metrics_unavailable',
|
||||
}
|
||||
_NODE_METRICS_FAILURES[timeframe] = {'error': error, 'ts': time.monotonic()}
|
||||
print(f"[ProxMenux] node metrics ({timeframe}): {exc}", file=sys.stderr, flush=True)
|
||||
return _node_metrics_fallback(timeframe, error)
|
||||
payload = {**payload, 'last_checked': int(time.time()), 'cache_status': 'fresh'}
|
||||
_NODE_METRICS_CACHE[timeframe] = {'payload': payload, 'ts': time.monotonic()}
|
||||
_NODE_METRICS_FAILURES.pop(timeframe, None)
|
||||
return payload
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _compute_node_metrics_payload(timeframe: str) -> dict:
|
||||
"""Fetch and shape one node RRD snapshot within a bounded query budget."""
|
||||
valid_timeframes = ('hour', 'day', 'week', 'month', 'year')
|
||||
if timeframe not in valid_timeframes:
|
||||
return None
|
||||
raise ValueError('Invalid timeframe')
|
||||
|
||||
deadline = time.monotonic() + _NODE_METRICS_TIMEOUT
|
||||
local_node = get_proxmox_node_name()
|
||||
|
||||
zfs_arc_size = 0
|
||||
@@ -11954,16 +11972,70 @@ def _compute_node_metrics_payload(timeframe: str) -> dict | None:
|
||||
rrd_result = subprocess.run(
|
||||
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
'--timeframe', timeframe, '--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
capture_output=True, text=True, timeout=_NODE_METRICS_TIMEOUT,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
raise _NodeMetricsError({
|
||||
'error': 'Proxmox metrics query timed out',
|
||||
'code': 'metrics_timeout',
|
||||
'details': 'The metrics query exceeded the 30-second limit. It can be retried without restarting any services.',
|
||||
})
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise _NodeMetricsError({'error': 'Proxmox metrics command failed', 'raw': str(exc)[:500]})
|
||||
if rrd_result.returncode != 0:
|
||||
return None
|
||||
stderr_str = (rrd_result.stderr or '') + (rrd_result.stdout or '')
|
||||
stderr_lower = stderr_str.lower()
|
||||
if 'mmaping file' in stderr_lower and 'invalid argument' in stderr_lower:
|
||||
raise _NodeMetricsError({
|
||||
'error': 'Proxmox RRD database is corrupt',
|
||||
'details': (
|
||||
'The host metrics file Proxmox keeps under '
|
||||
'/var/lib/rrdcached/db/pve-node-9.0/ failed to '
|
||||
'memory-map (Invalid argument). This is a Proxmox-side '
|
||||
'data-store issue, not a Monitor bug.'
|
||||
),
|
||||
'suggestion': (
|
||||
'Stop pvestatd + pve-cluster + rrdcached, move the '
|
||||
'broken RRD aside, restart the services. Proxmox will '
|
||||
'rebuild the RRD from scratch (history is lost).'
|
||||
),
|
||||
'raw': stderr_str.strip()[:500],
|
||||
})
|
||||
if 'no such file' in stderr_lower or 'no such node' in stderr_lower or 'does not exist' in stderr_lower:
|
||||
raise _NodeMetricsError({
|
||||
'error': 'Proxmox node name mismatch',
|
||||
'details': (
|
||||
f"pvesh could not find node '{local_node}'. The "
|
||||
'usual cause is that the host was renamed after '
|
||||
'Proxmox was installed, so /etc/pve/nodes/ still '
|
||||
'carries the old name. This is a Proxmox-side '
|
||||
'config issue, not a Monitor bug.'
|
||||
),
|
||||
'suggestion': 'Compare `hostname` with `ls /etc/pve/nodes/` — they must match.',
|
||||
'raw': stderr_str.strip()[:500],
|
||||
})
|
||||
if 'rrd' in stderr_lower or 'empty' in stderr_lower:
|
||||
raise _NodeMetricsError({
|
||||
'error': 'Proxmox RRD data not available',
|
||||
'details': 'The RRD database appears empty. Proxmox may not have collected metrics yet (fresh install) or rrdcached was down at boot.',
|
||||
'suggestion': 'systemctl restart rrdcached pvestatd ; wait ~5 min and reload this page.',
|
||||
'raw': stderr_str.strip()[:500],
|
||||
})
|
||||
raise _NodeMetricsError({
|
||||
'error': 'Proxmox metrics command failed',
|
||||
'details': 'pvesh exited non-zero. Check Proxmox host status.',
|
||||
'raw': stderr_str.strip()[:500],
|
||||
})
|
||||
|
||||
try:
|
||||
rrd_data = json.loads(rrd_result.stdout)
|
||||
if not isinstance(rrd_data, list) or any(not isinstance(item, dict) for item in rrd_data):
|
||||
raise ValueError('Expected an array of RRD points')
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
raise _NodeMetricsError({
|
||||
'error': 'Proxmox returned invalid metrics data',
|
||||
'code': 'metrics_invalid_data',
|
||||
})
|
||||
|
||||
for item in rrd_data:
|
||||
if 'arcsize' in item:
|
||||
@@ -11988,34 +12060,35 @@ def _compute_node_metrics_payload(timeframe: str) -> dict | None:
|
||||
}
|
||||
|
||||
def _pvesh_rrd(cf):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
try:
|
||||
extra = subprocess.run(
|
||||
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
'--timeframe', timeframe, '--cf', cf,
|
||||
'--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
capture_output=True, text=True, timeout=remaining,
|
||||
)
|
||||
if extra.returncode == 0 and extra.stdout:
|
||||
return json.loads(extra.stdout)
|
||||
points = json.loads(extra.stdout)
|
||||
if isinstance(points, list) and all(isinstance(item, dict) for item in points):
|
||||
return points
|
||||
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
# PVE supports AVERAGE and MAX, not MIN. Share MAX across both charts.
|
||||
cf_max = _pvesh_rrd('MAX') if timeframe in ('week', 'month') else None
|
||||
|
||||
def _build_stats(field_key, scale=1.0):
|
||||
native = _stats_native(field_key, scale)
|
||||
if native is None:
|
||||
return None
|
||||
if timeframe in ('week', 'month'):
|
||||
cf_max = _pvesh_rrd('MAX')
|
||||
if cf_max:
|
||||
vals = _values_from(cf_max, field_key, scale)
|
||||
if vals:
|
||||
native['max'] = max(vals)
|
||||
cf_min = _pvesh_rrd('MIN')
|
||||
if cf_min:
|
||||
vals = _values_from(cf_min, field_key, scale)
|
||||
if vals:
|
||||
native['min'] = min(vals)
|
||||
if cf_max:
|
||||
vals = _values_from(cf_max, field_key, scale)
|
||||
if vals:
|
||||
native['max'] = max(vals)
|
||||
return native
|
||||
|
||||
period_stats = {
|
||||
@@ -12053,28 +12126,17 @@ def _compute_node_metrics_payload(timeframe: str) -> dict | None:
|
||||
'data': rrd_data,
|
||||
'period_stats': period_stats,
|
||||
}
|
||||
_node_metrics_cache_set(timeframe, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _node_metrics_prewarmer_loop():
|
||||
"""Keep `_NODE_METRICS_CACHE['hour']` hot so the Overview page's
|
||||
default view (CPU + Memory charts, 1-hour range) never waits on
|
||||
`pvesh get rrddata`. Only `hour` is prewarmed — the other
|
||||
timeframes (day/week/month/year) are lazy-cached on first click
|
||||
and stick around for the 120 s TTL. Prewarming every timeframe
|
||||
burned ~30 % of a core continuously against pvesh for data
|
||||
nobody was looking at, and week/month each cost 3 pvesh calls
|
||||
(base + MAX + MIN)."""
|
||||
time.sleep(3) # let the app finish importing before the first pass
|
||||
"""Prewarm the Overview's default range using the same single-flight cache."""
|
||||
time.sleep(3)
|
||||
while True:
|
||||
try:
|
||||
_compute_node_metrics_payload('hour')
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] node-metrics prewarmer error: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
# Refresh well before the 120 s TTL expires so the user never
|
||||
# hits a cold cache during a natural page open.
|
||||
_get_node_metrics_payload('day', max_age=90.0)
|
||||
except _NodeMetricsError:
|
||||
pass # The shared fetch path already records the failure.
|
||||
time.sleep(90)
|
||||
|
||||
|
||||
@@ -12105,9 +12167,9 @@ def _vm_modal_prewarmer_pass():
|
||||
f'/api/vms/{vmid}/backups', 'backups'),
|
||||
]
|
||||
if vm_type == 'lxc':
|
||||
import lxc_apps
|
||||
lxc_apps.load_sidecar(vmid)
|
||||
endpoints.extend([
|
||||
(_vm_apps_cache, _VM_APPS_TTL, api_vm_apps_get,
|
||||
f'/api/vms/{vmid}/apps', 'apps'),
|
||||
(_vm_schedule_cache, _VM_SCHEDULE_TTL, api_vm_apps_schedule,
|
||||
f'/api/vms/{vmid}/schedule', 'schedule'),
|
||||
(_vm_mounts_cache, _VM_MOUNTS_TTL, api_lxc_mount_points,
|
||||
@@ -12176,249 +12238,17 @@ def _vm_modal_prewarmer_loop():
|
||||
@app.route('/api/node/metrics', methods=['GET'])
|
||||
@require_auth
|
||||
def api_node_metrics():
|
||||
"""Get historical metrics (RRD data) for the node.
|
||||
|
||||
Per-timeframe cached for ~10 s so the two dashboard panels that mount
|
||||
together don't hit `pvesh` twice; see `_NODE_METRICS_CACHE` comment.
|
||||
"""
|
||||
"""Share one cached RRD snapshot across the dashboard's charts."""
|
||||
timeframe = request.args.get('timeframe', 'week')
|
||||
if timeframe not in _NODE_METRICS_LOCKS:
|
||||
return jsonify({'error': 'Invalid timeframe. Must be one of: hour, day, week, month, year'}), 400
|
||||
try:
|
||||
timeframe = request.args.get('timeframe', 'week') # hour, day, week, month, year
|
||||
|
||||
# Validate timeframe
|
||||
valid_timeframes = ['hour', 'day', 'week', 'month', 'year']
|
||||
if timeframe not in valid_timeframes:
|
||||
return jsonify({'error': f'Invalid timeframe. Must be one of: {", ".join(valid_timeframes)}'}), 400
|
||||
|
||||
# Serve from cache when fresh — completely skips the pvesh call.
|
||||
cached = _node_metrics_cache_get(timeframe)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
|
||||
# Get local node name
|
||||
# local_node = socket.gethostname()
|
||||
local_node = get_proxmox_node_name()
|
||||
|
||||
# print(f"[v0] Local node: {local_node}")
|
||||
pass
|
||||
|
||||
|
||||
zfs_arc_size = 0
|
||||
try:
|
||||
with open('/proc/spl/kstat/zfs/arcstats', 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('size'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
zfs_arc_size = int(parts[2])
|
||||
break
|
||||
except (FileNotFoundError, PermissionError, ValueError):
|
||||
# ZFS not available or no access
|
||||
pass
|
||||
|
||||
# Get RRD data for the node
|
||||
|
||||
rrd_result = subprocess.run(['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
'--timeframe', timeframe, '--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
|
||||
# Detect well-known Proxmox-side failures BEFORE trying to parse
|
||||
# the JSON. These are PVE host problems (rrdcached down, RRD file
|
||||
# corrupt, node-name mismatch). None of them are caused by the
|
||||
# Monitor itself — surface a specific message so the operator
|
||||
# doesn't blame ProxMenux for a Proxmox-host data-store issue.
|
||||
if rrd_result.returncode != 0:
|
||||
stderr_str = (rrd_result.stderr or '') + (rrd_result.stdout or '')
|
||||
stderr_lower = stderr_str.lower()
|
||||
if 'mmaping file' in stderr_lower and 'invalid argument' in stderr_lower:
|
||||
# Corrupt RRD file on disk. Operator must recreate it.
|
||||
return jsonify({
|
||||
'error': 'Proxmox RRD database is corrupt',
|
||||
'details': (
|
||||
'The host metrics file Proxmox keeps under '
|
||||
'/var/lib/rrdcached/db/pve-node-9.0/ failed to '
|
||||
'memory-map (Invalid argument). This is a Proxmox-side '
|
||||
'data-store issue, not a Monitor bug.'
|
||||
),
|
||||
'suggestion': (
|
||||
'Stop pvestatd + pve-cluster + rrdcached, move the '
|
||||
'broken RRD aside, restart the services. Proxmox will '
|
||||
'rebuild the RRD from scratch (history is lost).'
|
||||
),
|
||||
'raw': stderr_str.strip()[:500],
|
||||
}), 503
|
||||
if 'no such file' in stderr_lower or 'no such node' in stderr_lower or 'does not exist' in stderr_lower:
|
||||
return jsonify({
|
||||
'error': 'Proxmox node name mismatch',
|
||||
'details': (
|
||||
f"pvesh could not find node '{local_node}'. The "
|
||||
'usual cause is that the host was renamed after '
|
||||
'Proxmox was installed, so /etc/pve/nodes/ still '
|
||||
'carries the old name. This is a Proxmox-side '
|
||||
'config issue, not a Monitor bug.'
|
||||
),
|
||||
'suggestion': 'Compare `hostname` with `ls /etc/pve/nodes/` — they must match.',
|
||||
'raw': stderr_str.strip()[:500],
|
||||
}), 503
|
||||
if 'rrd' in stderr_lower or 'empty' in stderr_lower:
|
||||
return jsonify({
|
||||
'error': 'Proxmox RRD data not available',
|
||||
'details': 'The RRD database appears empty. Proxmox may not have collected metrics yet (fresh install) or rrdcached was down at boot.',
|
||||
'suggestion': 'systemctl restart rrdcached pvestatd ; wait ~5 min and reload this page.',
|
||||
'raw': stderr_str.strip()[:500],
|
||||
}), 503
|
||||
return jsonify({
|
||||
'error': 'Proxmox metrics command failed',
|
||||
'details': 'pvesh exited non-zero. Check Proxmox host status.',
|
||||
'raw': stderr_str.strip()[:500],
|
||||
}), 503
|
||||
|
||||
if rrd_result.returncode == 0:
|
||||
rrd_data = json.loads(rrd_result.stdout)
|
||||
|
||||
# PVE 9.x exposes the actual ARC history as `arcsize` in RRD;
|
||||
# the previous code ignored it and stamped every point with
|
||||
# the live ARC size, producing a flat band at the current
|
||||
# value (issue: ZFS ARC line painted full-bar). Use the real
|
||||
# series when present so the chart matches Proxmox's own
|
||||
# Summary view. On older PVE that doesn't expose `arcsize`,
|
||||
# fall back to the live value as a constant placeholder.
|
||||
for item in rrd_data:
|
||||
if 'arcsize' in item:
|
||||
item['zfsarc'] = item['arcsize']
|
||||
elif zfs_arc_size > 0 and ('zfsarc' not in item or item.get('zfsarc', 0) == 0):
|
||||
item['zfsarc'] = zfs_arc_size
|
||||
|
||||
# Period stats — computed BEFORE downsampling so the
|
||||
# AVG/MAX/MIN header in the chart reflects real per-minute
|
||||
# extremes instead of averages.
|
||||
#
|
||||
# Three sources depending on the timeframe:
|
||||
#
|
||||
# - hour/day → PVE returns 1-min raw points. AVG/MAX/MIN
|
||||
# of the in-memory list IS the truth.
|
||||
#
|
||||
# - week/month → PVE already downsamples to 30-min /
|
||||
# ~1-hour points using consolidation function AVG, so
|
||||
# the in-memory points are already averages. Taking
|
||||
# max() of them gives "max of averages", NOT the real
|
||||
# peak. We issue two extra pvesh calls per request
|
||||
# (`--cf MAX` and `--cf MIN`) to recover the real
|
||||
# extremes from PVE's own RRD consolidation. The
|
||||
# extra calls add ~150 ms — only on week/month and
|
||||
# only when the chart loads, so the overhead is small.
|
||||
def _values_from(items, field_key, scale=1.0):
|
||||
return [item[field_key] * scale for item in items
|
||||
if isinstance(item.get(field_key), (int, float))
|
||||
and not isinstance(item[field_key], bool)
|
||||
and item[field_key] is not None]
|
||||
|
||||
def _stats_native(field_key, scale=1.0):
|
||||
values = _values_from(rrd_data, field_key, scale)
|
||||
if not values:
|
||||
return None
|
||||
return {
|
||||
'avg': sum(values) / len(values),
|
||||
'max': max(values),
|
||||
'min': min(values),
|
||||
}
|
||||
|
||||
def _pvesh_rrd(cf):
|
||||
"""One extra pvesh call with a non-default CF.
|
||||
Returns the parsed list or None on any failure — caller
|
||||
falls back to the AVG-based numbers."""
|
||||
try:
|
||||
extra = subprocess.run(
|
||||
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
'--timeframe', timeframe, '--cf', cf,
|
||||
'--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if extra.returncode == 0 and extra.stdout:
|
||||
return json.loads(extra.stdout)
|
||||
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _build_stats(field_key, scale=1.0):
|
||||
native = _stats_native(field_key, scale)
|
||||
if native is None:
|
||||
return None
|
||||
# On week/month, the points we already have are AVG.
|
||||
# Try to upgrade max/min to the real RRD extremes.
|
||||
if timeframe in ('week', 'month'):
|
||||
cf_max = _pvesh_rrd('MAX')
|
||||
if cf_max:
|
||||
vals = _values_from(cf_max, field_key, scale)
|
||||
if vals:
|
||||
native['max'] = max(vals)
|
||||
cf_min = _pvesh_rrd('MIN')
|
||||
if cf_min:
|
||||
vals = _values_from(cf_min, field_key, scale)
|
||||
if vals:
|
||||
native['min'] = min(vals)
|
||||
return native
|
||||
|
||||
period_stats = {
|
||||
# cpu: RRD stores fraction 0-1, surface as %.
|
||||
'cpu': _build_stats('cpu', scale=100.0),
|
||||
# memory_used: bytes → GB so units match the chart.
|
||||
'memory_used': _build_stats('memused', scale=1 / (1024 ** 3)),
|
||||
}
|
||||
|
||||
# 24h downsampling: RRD returns ~1440 minute-level points which
|
||||
# plots as a dense thicket of vertical spikes. Group into 5-min
|
||||
# buckets and average each numeric field — same shape that
|
||||
# `get_temperature_history` uses for its 24h view so the look
|
||||
# is consistent across the dashboard's 24h charts.
|
||||
if timeframe == 'day' and rrd_data:
|
||||
bucket_seconds = 300 # 5-min
|
||||
buckets = {}
|
||||
for item in rrd_data:
|
||||
t = item.get('time')
|
||||
if t is None:
|
||||
continue
|
||||
bk = (int(t) // bucket_seconds) * bucket_seconds
|
||||
if bk not in buckets:
|
||||
buckets[bk] = {'_count': 0, '_sums': {}}
|
||||
b = buckets[bk]
|
||||
b['_count'] += 1
|
||||
for k, v in item.items():
|
||||
if k == 'time' or not isinstance(v, (int, float)) or isinstance(v, bool):
|
||||
continue
|
||||
b['_sums'][k] = b['_sums'].get(k, 0) + v
|
||||
rrd_data = []
|
||||
for bk in sorted(buckets.keys()):
|
||||
b = buckets[bk]
|
||||
point = {'time': bk}
|
||||
for k, total in b['_sums'].items():
|
||||
point[k] = total / b['_count']
|
||||
rrd_data.append(point)
|
||||
|
||||
payload = {
|
||||
'node': local_node,
|
||||
'timeframe': timeframe,
|
||||
'data': rrd_data,
|
||||
# AVG/MAX/MIN computed over the raw (pre-downsampling)
|
||||
# points so the chart header captures real per-minute
|
||||
# extremes even on multi-day timeframes.
|
||||
'period_stats': period_stats,
|
||||
}
|
||||
_node_metrics_cache_set(timeframe, payload)
|
||||
return jsonify(payload)
|
||||
# Note: the old `else` branch that handled rrd_result.returncode != 0
|
||||
# was removed — the early-return block above now catches every
|
||||
# non-zero exit BEFORE we ever attempt json.loads(), so reaching
|
||||
# this point with returncode != 0 is impossible.
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# pvesh returned invalid JSON - likely empty RRD
|
||||
return jsonify({
|
||||
'error': 'Proxmox RRD data not available',
|
||||
'details': 'pvesh returned non-JSON output. The RRD database is likely empty (fresh install where pvestatd has not run yet) or the rrdcached daemon is down.',
|
||||
'suggestion': 'systemctl restart rrdcached pvestatd ; wait ~5 min and reload.',
|
||||
}), 503
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify(_get_node_metrics_payload(timeframe))
|
||||
except _NodeMetricsError as exc:
|
||||
response = jsonify(exc.payload)
|
||||
response.status_code = 503
|
||||
response.headers['Retry-After'] = str(int(_NODE_METRICS_RETRY_DELAY))
|
||||
return response
|
||||
|
||||
@app.route('/api/logs/counts', methods=['GET'])
|
||||
@require_auth
|
||||
@@ -13572,18 +13402,10 @@ def api_lxc_updates_detection_set():
|
||||
def api_vm_apps_get(vmid):
|
||||
try:
|
||||
import lxc_apps
|
||||
cached = _vm_cache_get(_vm_apps_cache, vmid, _VM_APPS_TTL)
|
||||
if cached is not None:
|
||||
# Annotated on the way out, never on the way in: this cache is
|
||||
# invalidated by events, not by time, and the Docker inventory it
|
||||
# reads is built asynchronously. Annotating before storing would
|
||||
# freeze whatever was known at first read — for an app registered
|
||||
# before the first scan, permanently no available version.
|
||||
lxc_apps.annotate_delegated_apps(cached.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
|
||||
return jsonify(cached)
|
||||
sidecar = lxc_apps.load_sidecar(vmid)
|
||||
payload = sidecar if sidecar else {'vmid': vmid, 'apps': []}
|
||||
_vm_cache_put(_vm_apps_cache, vmid, payload)
|
||||
# Read the shared, file-versioned snapshot; annotate only on the way
|
||||
# out so asynchronously collected Docker metadata remains current.
|
||||
lxc_apps.annotate_delegated_apps(payload.get('apps') or [], _get_lxc_docker_inventory_map().get(str(vmid)))
|
||||
return jsonify(payload)
|
||||
except Exception as e:
|
||||
@@ -13599,7 +13421,6 @@ def api_vm_apps_add(vmid):
|
||||
ok, result = lxc_apps.add_app(vmid, payload)
|
||||
if not ok:
|
||||
return jsonify({'error': result}), 400
|
||||
_vm_cache_put(_vm_apps_cache, vmid, result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -13635,7 +13456,6 @@ def api_vm_apps_update(vmid, app_id):
|
||||
if not ok:
|
||||
code = 404 if 'not found' in str(result).lower() else 400
|
||||
return jsonify({'error': result}), code
|
||||
_vm_cache_put(_vm_apps_cache, vmid, result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -13648,7 +13468,6 @@ def api_vm_apps_delete_one(vmid, app_id):
|
||||
import lxc_apps
|
||||
ok = lxc_apps.delete_app(vmid, app_id)
|
||||
sidecar = lxc_apps.load_sidecar(vmid) or {'vmid': vmid, 'apps': []}
|
||||
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
|
||||
return jsonify({**sidecar, 'success': ok, 'app_id': app_id}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -13661,7 +13480,6 @@ def api_vm_apps_delete_all(vmid):
|
||||
import lxc_apps
|
||||
ok = lxc_apps.delete_all(vmid)
|
||||
sidecar = {'vmid': vmid, 'apps': []}
|
||||
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
|
||||
return jsonify({**sidecar, 'success': ok}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -13675,7 +13493,6 @@ def api_vm_apps_check_one(vmid, app_id):
|
||||
sidecar = lxc_apps.check_app(vmid, app_id, force=True)
|
||||
if not sidecar:
|
||||
return jsonify({'error': 'app not found'}), 404
|
||||
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
|
||||
return jsonify(sidecar)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -13689,7 +13506,6 @@ def api_vm_apps_check_all(vmid):
|
||||
sidecar = lxc_apps.check_all(vmid, force=True)
|
||||
if not sidecar:
|
||||
sidecar = {'vmid': vmid, 'apps': []}
|
||||
_vm_cache_put(_vm_apps_cache, vmid, sidecar)
|
||||
return jsonify(sidecar)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -14077,7 +13893,6 @@ def api_vm_apps_dismiss(vmid):
|
||||
ok, result = lxc_apps.set_dismissed_slug(vmid, slug, dismissed)
|
||||
if not ok:
|
||||
return jsonify({'error': result}), 400
|
||||
_vm_cache_put(_vm_apps_cache, vmid, result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -14398,11 +14213,8 @@ def _finalize_lxc_update(
|
||||
vmid,
|
||||
[item[4:] for item in requested if item.startswith('app:')],
|
||||
)
|
||||
refreshed_sidecar = lxc_apps.check_all(vmid, force=True)
|
||||
refreshed_sidecar = refreshed_sidecar or {'vmid': vmid, 'apps': []}
|
||||
_vm_cache_put(_vm_apps_cache, vmid, refreshed_sidecar)
|
||||
lxc_apps.check_all(vmid, force=True)
|
||||
except Exception as exc:
|
||||
_vm_cache_invalidate(vmid, _vm_apps_cache)
|
||||
verification_errors.append(f'application refresh failed: {exc}')
|
||||
docker_attempted = refresh_docker_inventory or any(
|
||||
target_id.startswith('docker-') for target_id in requested
|
||||
@@ -15109,18 +14921,19 @@ def api_vms_modal_cache_all():
|
||||
frontend replace the current 84-request warm-up (4 endpoints ×
|
||||
~21 guests) with a single fetch on page load.
|
||||
|
||||
Reads **exclusively** from the in-memory caches populated by
|
||||
Reads modal caches populated by
|
||||
the backend prewarmer (`_vm_modal_prewarmer_loop`). Never falls
|
||||
through to a live handler call — that would let a single cold
|
||||
guest block the whole bulk response for 10-20s. If a guest is
|
||||
not yet cached the corresponding field is `null` and the client
|
||||
fetches that one endpoint dirigido on demand.
|
||||
guest block the whole bulk response for 10-20s. Registered apps use
|
||||
the shared sidecar snapshot, checking only local file metadata for changes.
|
||||
If a guest's other modal data is not yet cached, that field is `null`
|
||||
and the client fetches the corresponding endpoint on demand.
|
||||
|
||||
Trade-off: for the ~20-70s window right after `systemctl
|
||||
restart proxmenux-monitor` some fields come back `null`; the
|
||||
client transparently falls back to per-endpoint fetches for
|
||||
those. Once the initial warm-up finishes the entire response
|
||||
is served from dict reads (<20ms even with 30+ guests).
|
||||
those. Once the initial warm-up finishes, registered apps reuse their
|
||||
parsed snapshots and the other fields are served from dict reads.
|
||||
|
||||
Response shape:
|
||||
{
|
||||
@@ -15150,7 +14963,8 @@ def api_vms_modal_cache_all():
|
||||
'backups': _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL),
|
||||
}
|
||||
if vm_type == 'lxc':
|
||||
entry['apps'] = _vm_cache_get(_vm_apps_cache, vmid, _VM_APPS_TTL)
|
||||
import lxc_apps
|
||||
entry['apps'] = lxc_apps.load_sidecar(vmid) or {'vmid': vmid, 'apps': []}
|
||||
entry['suggestions'] = _vm_cache_get(
|
||||
_vm_app_suggestions_cache, vmid,
|
||||
_VM_APP_SUGGESTIONS_TTL,
|
||||
@@ -21887,7 +21701,8 @@ def _compose_scheduled_update_command(vmid: int, target: str, targets: list[str]
|
||||
and cmd == _DOCKER_ENGINE_INTEGRATED_COMMAND
|
||||
)
|
||||
if cmd and not is_integrated_docker_command:
|
||||
parts.append(cmd)
|
||||
# Protect each legacy launcher before joining the multi-app plan.
|
||||
parts.append(lxc_apps.protect_download_update_command(cmd))
|
||||
selected_projects = {
|
||||
item.split(":", 1)[1]
|
||||
for item in targets
|
||||
@@ -21961,7 +21776,7 @@ def _scheduled_helper_enabled(vmid: int, target: str, targets: list[str]) -> boo
|
||||
# conservatively instead of running both methods for the same app.
|
||||
if any((app.get("update_command") or "").strip() for app in matching_apps):
|
||||
return False
|
||||
return True
|
||||
return lxc_apps.helper_update_selected(vmid, helper_slug, targets)
|
||||
|
||||
|
||||
def _resolve_bulk_update_plan(vmid: int, targets: list[str]) -> dict:
|
||||
@@ -22004,6 +21819,7 @@ def _resolve_bulk_update_plan(vmid: int, targets: list[str]) -> dict:
|
||||
|
||||
def add_command(command: str) -> None:
|
||||
command = str(command or '').strip()
|
||||
command = lxc_apps.protect_download_update_command(command)
|
||||
if command and command not in commands:
|
||||
commands.append(command)
|
||||
|
||||
@@ -22041,7 +21857,8 @@ def _resolve_bulk_update_plan(vmid: int, targets: list[str]) -> dict:
|
||||
command = str(app.get('update_command') or '').strip()
|
||||
if command:
|
||||
add_command(command)
|
||||
elif helper_enabled and app.get('helper_slug'):
|
||||
elif (helper_enabled and app.get('update_method') == 'helper'
|
||||
and _scheduled_helper_enabled(vmid, 'app', [target_id])):
|
||||
run_helper = True
|
||||
else:
|
||||
unavailable.append({'target': target_id, 'reason': 'no executable update method is available'})
|
||||
@@ -22249,6 +22066,36 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
registered_apps = (lxc_apps._read_sidecar(vmid) or {}).get("apps") or []
|
||||
except Exception:
|
||||
registered_apps = []
|
||||
# A saved schedule can outlive an application's updater choice. Keep
|
||||
# its configuration, but explicitly report unavailable targets instead
|
||||
# of silently treating an OS-only/no-op run as a complete app update.
|
||||
helper_ready = _scheduled_helper_enabled(vmid, 'app', targets)
|
||||
available_app_ids = {
|
||||
str(app.get('id')) for app in registered_apps
|
||||
if not app.get('managed_oci_app_id') and (
|
||||
(app.get('update_command') or '').strip()
|
||||
or app.get('helper_slug') == 'docker'
|
||||
or (helper_ready and app.get('update_method') == 'helper'
|
||||
and _scheduled_helper_enabled(vmid, 'app', [f"app:{app.get('id')}"]))
|
||||
)
|
||||
}
|
||||
filtered_targets = []
|
||||
for value in targets:
|
||||
if value == 'apps':
|
||||
eligible = [f"app:{app.get('id')}" for app in registered_apps
|
||||
if str(app.get('id')) in available_app_ids
|
||||
and app.get('helper_slug') != 'docker']
|
||||
if eligible:
|
||||
filtered_targets.extend(eligible)
|
||||
else:
|
||||
deferred_targets.append(value)
|
||||
reasons.append('no application update method has been selected or is available')
|
||||
elif value.startswith('app:') and value.split(':', 1)[1] not in available_app_ids:
|
||||
deferred_targets.append(value)
|
||||
reasons.append(f'{value}: update method is not selected or no longer available')
|
||||
else:
|
||||
filtered_targets.append(value)
|
||||
targets = list(dict.fromkeys(filtered_targets))
|
||||
if any(value.startswith("docker-") for value in targets):
|
||||
docker_registered = any(app.get("helper_slug") == "docker" for app in registered_apps)
|
||||
if not docker_registered:
|
||||
@@ -22721,22 +22568,16 @@ if __name__ == '__main__':
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] app-updates startup emitter failed to arm: {e}")
|
||||
|
||||
# ── Node-metrics Prewarmer ──
|
||||
# Keeps `_NODE_METRICS_CACHE` hot for every timeframe (hour / day /
|
||||
# week / month / year) on a 30 s cadence, so the Overview page's
|
||||
# CPU + memory charts never wait on `pvesh get rrddata` when the
|
||||
# user opens the dashboard. Cache TTL is 60 s; the loop refreshes
|
||||
# every 30 s, giving 30 s of headroom against transient pvesh
|
||||
# latency.
|
||||
# Prewarm only the Overview's default day range; other ranges are lazy.
|
||||
try:
|
||||
metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer')
|
||||
metrics_thread.start()
|
||||
print("[ProxMenux] Node-metrics prewarmer started (30s interval)")
|
||||
print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)")
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}")
|
||||
|
||||
# ── VM/CT modal-cache prewarmer ──
|
||||
# Keeps _vm_details_cache / _vm_backups_cache / _vm_apps_cache /
|
||||
# Keeps _vm_details_cache / _vm_backups_cache / app snapshots /
|
||||
# _vm_schedule_cache warm from the backend so the "Loading
|
||||
# configuration..." message never appears on modal open, even
|
||||
# after the tab has been closed for minutes. The React-side
|
||||
|
||||
+147
-10
@@ -26,6 +26,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import copy
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import json
|
||||
@@ -418,15 +419,43 @@ def _now_iso() -> str:
|
||||
return datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
|
||||
_sidecar_cache: dict = {}
|
||||
_sidecar_cache_lock = threading.RLock()
|
||||
_sidecar_revision = 0
|
||||
|
||||
|
||||
def _sidecar_signature(stat) -> tuple:
|
||||
return (stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size)
|
||||
|
||||
|
||||
def _publish_sidecar_snapshot(path: str, data: dict, signature: tuple) -> dict:
|
||||
"""Publish under _sidecar_cache_lock; revisions exist only in memory."""
|
||||
global _sidecar_revision
|
||||
_sidecar_revision = max(_sidecar_revision + 1, int(time.time() * 1000))
|
||||
snapshot = _migrate_legacy(copy.deepcopy(data))
|
||||
_migrate_update_methods(snapshot)
|
||||
snapshot['_revision'] = _sidecar_revision
|
||||
_sidecar_cache[path] = (signature, snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _read_sidecar(vmid) -> Optional[dict]:
|
||||
path = _sidecar_path(vmid)
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return _migrate_legacy(data)
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
pass
|
||||
with _sidecar_cache_lock:
|
||||
try:
|
||||
signature = _sidecar_signature(os.stat(path))
|
||||
cached = _sidecar_cache.get(path)
|
||||
if cached is not None and cached[0] == signature:
|
||||
return copy.deepcopy(cached[1])
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
signature = _sidecar_signature(os.fstat(f.fileno()))
|
||||
if isinstance(data, dict):
|
||||
snapshot = _publish_sidecar_snapshot(path, data, signature)
|
||||
return copy.deepcopy(snapshot)
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
pass
|
||||
_sidecar_cache.pop(path, None)
|
||||
return None
|
||||
|
||||
|
||||
@@ -466,15 +495,107 @@ def _migrate_legacy(data: dict) -> dict:
|
||||
"updated_at": data.get("updated_at") or _now_iso()}
|
||||
|
||||
|
||||
def _migrate_update_methods(data: dict) -> None:
|
||||
"""Preserve saved choices, never turn detection into updater consent.
|
||||
|
||||
Old commands and explicitly saved bulk/enabled schedule selections keep
|
||||
working. New registrations always carry update_method, so an old `apps`
|
||||
wildcard cannot opt newly registered applications into Helper-Scripts.
|
||||
Projection is read-only; the next normal sidecar write persists it.
|
||||
"""
|
||||
schedule = data.get("schedule") or {}
|
||||
selected = set((data.get("bulk_update") or {}).get("targets") or [])
|
||||
if schedule.get("enabled"):
|
||||
targets = schedule.get("targets")
|
||||
if not targets:
|
||||
targets = ["apps"] if schedule.get("target", "both") in ("app", "both") else []
|
||||
selected.update(targets)
|
||||
for app in data.get("apps") or []:
|
||||
if "update_method" in app:
|
||||
continue
|
||||
if (app.get("update_command") or "").strip():
|
||||
app["update_method"] = "custom"
|
||||
elif (app.get("helper_slug") and app.get("helper_slug") not in ("docker", "adguard")
|
||||
and ("apps" in selected or f"app:{app.get('id')}" in selected)):
|
||||
app["update_method"] = "helper"
|
||||
else:
|
||||
app["update_method"] = "none"
|
||||
|
||||
|
||||
def protect_download_update_command(command: str) -> str:
|
||||
"""Guard the historical downloaded-shell launcher at execution time.
|
||||
|
||||
Only a literal, standalone wget/curl + shell -c launcher is recognised.
|
||||
Other custom commands are returned byte-for-byte, never evaluated here.
|
||||
Saved configuration is not rewritten. Grouping preserves && composition.
|
||||
"""
|
||||
launcher = re.fullmatch(
|
||||
r'''\s*(?P<prefix>PHS_SILENT=[01][ \t]+)?(?P<shell>(?:/bin/|/usr/bin/)?(?:bash|sh))[ \t]+-c[ \t]+"\$\((?P<fetch>[^\n]+)\)"\s*''',
|
||||
command,
|
||||
)
|
||||
if not launcher:
|
||||
return command
|
||||
fetch = re.fullmatch(
|
||||
r'''(?P<tool>wget|curl)[ \t]+(?P<flags>-qLO[ \t]+-|-qO[ \t]+-|-qO-|-fsSL|-fSL)[ \t]+(?P<quote>['"]?)(?P<url>https?://[A-Za-z0-9_./:%?=&+#@,~!;-]+)(?P=quote)''',
|
||||
launcher['fetch'],
|
||||
)
|
||||
if not fetch:
|
||||
return command
|
||||
# Require the original shell token to be literal too. Unquoted shell
|
||||
# operators or glob patterns are not this known launcher format.
|
||||
if not fetch['quote'] and any(c in fetch['url'] for c in '&;?'):
|
||||
return command
|
||||
flags = fetch['flags'].split()
|
||||
if ((fetch['tool'] == 'wget' and flags not in (['-qLO', '-'], ['-qO', '-'], ['-qO-']))
|
||||
or (fetch['tool'] == 'curl' and flags not in (['-fsSL'], ['-fSL']))):
|
||||
return command
|
||||
fetch_command = shlex.join([fetch['tool'], *flags, fetch['url']])
|
||||
invocation = (launcher['prefix'] or '') + launcher['shell']
|
||||
return (
|
||||
'(\n'
|
||||
f'_proxmenux_updater=$({fetch_command}) || {{\n'
|
||||
' echo "ERROR: updater download failed; nothing was executed." >&2\n'
|
||||
' exit 1\n'
|
||||
'}\n'
|
||||
'[ -n "$_proxmenux_updater" ] || {\n'
|
||||
' echo "ERROR: downloaded updater is empty; nothing was executed." >&2\n'
|
||||
' exit 1\n'
|
||||
'}\n'
|
||||
f'{invocation} -c "$_proxmenux_updater"\n'
|
||||
')'
|
||||
)
|
||||
|
||||
|
||||
def helper_update_selected(vmid, slug: str, targets=None) -> bool:
|
||||
"""Execution-time consent check, shared with the shell runner.
|
||||
|
||||
Wrapper provenance is independently verified by the caller. Duplicate
|
||||
registrations with different choices must not run a CT-wide helper.
|
||||
"""
|
||||
apps = (_read_sidecar(vmid) or {}).get("apps") or []
|
||||
matching = [app for app in apps if app.get("helper_slug") == slug
|
||||
and not app.get("managed_oci_app_id")]
|
||||
if not matching or any(app.get("update_method") != "helper"
|
||||
or (app.get("update_command") or "").strip() for app in matching):
|
||||
return False
|
||||
if targets is None or "apps" in targets:
|
||||
return True
|
||||
return any(f"app:{app.get('id')}" in targets for app in matching)
|
||||
|
||||
|
||||
def _write_sidecar(vmid, data: dict) -> bool:
|
||||
_migrate_update_methods(data)
|
||||
_ensure_dir()
|
||||
path = _sidecar_path(vmid)
|
||||
tmp = f"{path}.tmp.{os.getpid()}"
|
||||
try:
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
json.dump({k: v for k, v in data.items() if k != '_revision'}, f, indent=2, sort_keys=True)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
with _sidecar_cache_lock:
|
||||
os.replace(tmp, path)
|
||||
snapshot = _publish_sidecar_snapshot(path, data, _sidecar_signature(os.stat(path)))
|
||||
data['_revision'] = snapshot['_revision']
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"[ProxMenux] lxc_apps: could not write sidecar {path}: {e}")
|
||||
@@ -1092,6 +1213,17 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
|
||||
# two-step strategy payloads and normalize them on write.
|
||||
conf["update_strategy"] = "custom_override"
|
||||
|
||||
method = payload.get("update_method", "custom" if conf.get("update_command") else "none")
|
||||
if method not in ("none", "helper", "custom"):
|
||||
return _err("update_method must be none, helper or custom")
|
||||
if method == "custom" and not conf.get("update_command"):
|
||||
return _err("update_command is required for update_method=custom")
|
||||
if method != "custom" and conf.get("update_command"):
|
||||
return _err("update_command is only allowed for update_method=custom")
|
||||
if method == "helper" and (not hs or hs in ("docker", "adguard")):
|
||||
return _err("a supported helper_slug is required for update_method=helper")
|
||||
conf["update_method"] = method
|
||||
|
||||
# Optional per-app dismiss flag for the "no update method defined"
|
||||
# notice shown in the Updates tab. Only affects the notice card;
|
||||
# the App tab keeps its purple update signal regardless.
|
||||
@@ -4057,6 +4189,7 @@ def _summarise_app(app: dict) -> dict:
|
||||
"health_path": app.get("health_path"),
|
||||
"installed_version": state.get("installed_version"),
|
||||
"latest_version": state.get("latest_version"),
|
||||
"latest_published_at": state.get("latest_published_at"),
|
||||
"update_available": state.get("update_available"),
|
||||
"error": state.get("error"),
|
||||
"checked_at": state.get("checked_at"),
|
||||
@@ -4066,6 +4199,7 @@ def _summarise_app(app: dict) -> dict:
|
||||
# it) and whether the "no method" notice is suppressed for
|
||||
# this app.
|
||||
"update_command": app.get("update_command") or "",
|
||||
"update_method": app.get("update_method", "custom" if app.get("update_command") else "none"),
|
||||
# Compatibility field for older clients. The only supported
|
||||
# strategy is now replacement; legacy sidecars are normalized
|
||||
# in the API even before their next write.
|
||||
@@ -4273,7 +4407,10 @@ def get_active_apps() -> dict:
|
||||
apps = sidecar.get("apps") or []
|
||||
if not apps:
|
||||
continue
|
||||
out[str(vmid)] = [_summarise_app(a) for a in apps]
|
||||
out[str(vmid)] = [
|
||||
{**_summarise_app(a), 'state_revision': sidecar.get('_revision')}
|
||||
for a in apps
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -123,6 +123,20 @@ def _read_lxc_config(vmid: str) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _mount_target_key(target: str) -> str:
|
||||
"""Return a stable comparison key for a CT-side mount target.
|
||||
|
||||
Proxmox config accepts a trailing slash in ``mp=/path/`` while
|
||||
``/proc/<pid>/mounts`` reports the realised target as ``/path``.
|
||||
They name the same mount point, so comparisons must not treat the
|
||||
spelling difference as a runtime divergence. Keep the root path
|
||||
intact: stripping its only slash would turn it into an empty key.
|
||||
"""
|
||||
if target == "/":
|
||||
return target
|
||||
return target.rstrip("/")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type classification + source resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -605,14 +619,14 @@ def get_lxc_mount_points_static(vmid: str) -> dict[str, Any]:
|
||||
if running and host_pid:
|
||||
try:
|
||||
config_targets = {
|
||||
entry.get("target", "")
|
||||
_mount_target_key(entry.get("target", ""))
|
||||
for entry in config_entries
|
||||
if entry.get("target")
|
||||
}
|
||||
for rt in _read_ct_proc_mounts(host_pid):
|
||||
if not _REMOTE_FS_RE.match(rt.get("rt_fstype", "")):
|
||||
continue
|
||||
if rt.get("rt_target") in config_targets:
|
||||
if _mount_target_key(rt.get("rt_target", "")) in config_targets:
|
||||
continue
|
||||
ad_hoc_hint_count += 1
|
||||
except Exception:
|
||||
@@ -658,7 +672,9 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
|
||||
# mount point are I/O-bound. Serialised, a CT with 5+ binds
|
||||
# tripped Caddy's 3s reverse-proxy timeout.
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
rt_by_target: dict[str, dict[str, Any]] = {m["rt_target"]: m for m in rt_mounts}
|
||||
rt_by_target: dict[str, dict[str, Any]] = {
|
||||
_mount_target_key(m["rt_target"]): m for m in rt_mounts
|
||||
}
|
||||
|
||||
runtime_by_target: dict[str, dict[str, Any]] = {}
|
||||
matched_targets: set[str] = set()
|
||||
@@ -673,9 +689,10 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
|
||||
host_pid=host_pid if running else "",
|
||||
target=tgt,
|
||||
)
|
||||
live_target = bool(running and tgt and tgt in rt_by_target)
|
||||
target_key = _mount_target_key(tgt)
|
||||
live_target = bool(running and tgt and target_key in rt_by_target)
|
||||
health = _stat_via_host(host_pid, tgt) if live_target else None
|
||||
return entry, capacity, live_target, health
|
||||
return entry, capacity, target_key, live_target, health
|
||||
|
||||
if config_entries:
|
||||
max_workers = max(2, min(8, len(config_entries)))
|
||||
@@ -684,11 +701,11 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
|
||||
else:
|
||||
gathered = []
|
||||
|
||||
for entry, cap, live_target, health in gathered:
|
||||
for entry, cap, target_key, live_target, health in gathered:
|
||||
target = entry.get("target", "")
|
||||
rt_item: dict[str, Any] = {**cap}
|
||||
if live_target:
|
||||
rt = rt_by_target[target]
|
||||
rt = rt_by_target[target_key]
|
||||
rt_item.update({
|
||||
"runtime_mounted": True,
|
||||
"runtime_source": rt["rt_source"],
|
||||
@@ -698,7 +715,7 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
|
||||
"runtime_reachable": health["reachable"],
|
||||
"runtime_error": health["error"],
|
||||
})
|
||||
matched_targets.add(target)
|
||||
matched_targets.add(target_key)
|
||||
elif running:
|
||||
rt_item["runtime_mounted"] = False
|
||||
rt_item["runtime_error"] = "configured but not mounted"
|
||||
@@ -712,7 +729,7 @@ def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
|
||||
if running:
|
||||
ad_hoc_candidates = [
|
||||
rt for rt in rt_mounts
|
||||
if rt["rt_target"] not in matched_targets
|
||||
if _mount_target_key(rt["rt_target"]) not in matched_targets
|
||||
and _REMOTE_FS_RE.match(rt["rt_fstype"])
|
||||
]
|
||||
if ad_hoc_candidates:
|
||||
@@ -776,7 +793,9 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
|
||||
|
||||
# Index runtime mounts by their CT-side target path so we can
|
||||
# match a config entry to its current realised state in O(1).
|
||||
rt_by_target: dict[str, dict[str, Any]] = {m["rt_target"]: m for m in rt_mounts}
|
||||
rt_by_target: dict[str, dict[str, Any]] = {
|
||||
_mount_target_key(m["rt_target"]): m for m in rt_mounts
|
||||
}
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
matched_targets: set[str] = set()
|
||||
@@ -801,15 +820,16 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
|
||||
target=tgt,
|
||||
)
|
||||
host_src = _host_source_state(src)
|
||||
live_target = bool(running and tgt and tgt in rt_by_target)
|
||||
target_key = _mount_target_key(tgt)
|
||||
live_target = bool(running and tgt and target_key in rt_by_target)
|
||||
health = _stat_via_host(host_pid, tgt) if live_target else None
|
||||
return entry, classification, capacity, host_src, live_target, health
|
||||
return entry, classification, capacity, host_src, target_key, live_target, health
|
||||
|
||||
max_workers = max(2, min(8, len(config_entries) or 1))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
gathered = list(pool.map(_gather_one, config_entries))
|
||||
|
||||
for entry, cls, cap, host_src, live_target, health in gathered:
|
||||
for entry, cls, cap, host_src, target_key, live_target, health in gathered:
|
||||
source = entry.get("source", "")
|
||||
target = entry.get("target", "")
|
||||
|
||||
@@ -830,7 +850,7 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
|
||||
|
||||
# Runtime enrichment when CT is up.
|
||||
if live_target:
|
||||
rt = rt_by_target[target]
|
||||
rt = rt_by_target[target_key]
|
||||
item.update({
|
||||
"runtime_mounted": True,
|
||||
"runtime_source": rt["rt_source"],
|
||||
@@ -840,7 +860,7 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
|
||||
"runtime_reachable": health["reachable"],
|
||||
"runtime_error": health["error"],
|
||||
})
|
||||
matched_targets.add(target)
|
||||
matched_targets.add(target_key)
|
||||
elif running:
|
||||
# CT is running but the configured mount isn't in
|
||||
# /proc/<pid>/mounts — divergence. Could be a startup
|
||||
@@ -860,7 +880,7 @@ def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
|
||||
if running:
|
||||
ad_hoc_candidates = [
|
||||
rt for rt in rt_mounts
|
||||
if rt["rt_target"] not in matched_targets
|
||||
if _mount_target_key(rt["rt_target"]) not in matched_targets
|
||||
and _REMOTE_FS_RE.match(rt["rt_fstype"])
|
||||
]
|
||||
# Same parallelisation as the configured-mp loop: stat'ing
|
||||
|
||||
@@ -2586,6 +2586,11 @@ class PollingCollector:
|
||||
# once for each genuinely new available version. The persistent
|
||||
# history is stored in updates_available.json beside the scan.
|
||||
self._last_post_install_check = 0
|
||||
# Re-open that announcement once per service start, so a host
|
||||
# carrying optimizations it never applied surfaces them again
|
||||
# after a ProxMenux update or a node reboot instead of staying
|
||||
# silent forever. Consumed by the first check cycle.
|
||||
self._post_install_startup_reset_pending = True
|
||||
# Sprint 14.7: fingerprint (item_id → latest_version) of the
|
||||
# last managed-installs update notification, across all types
|
||||
# in the registry. A new notification fires when the
|
||||
@@ -3511,6 +3516,22 @@ class PollingCollector:
|
||||
|
||||
# ── Post-install function updates check (Sprint 12D) ────────────
|
||||
|
||||
def _reset_post_install_announcements(self):
|
||||
"""Re-open the optimization announcement after a service start.
|
||||
|
||||
``post_install_update`` sits behind a second gate the other
|
||||
update events don't have: a per-version history that keeps a
|
||||
pending optimization from being announced twice. The manager's
|
||||
``_EVENT_TYPES_RESET_ON_START`` already clears the delivery
|
||||
cooldown, so only that history has to be dropped here for the
|
||||
first cycle to report whatever is still pending.
|
||||
"""
|
||||
try:
|
||||
import post_install_versions
|
||||
post_install_versions.reset_notified_versions()
|
||||
except Exception as e:
|
||||
print(f"[PollingCollector] post-install history reset failed: {e}")
|
||||
|
||||
def _check_post_install_updates(self):
|
||||
"""Notify the operator when post-install functions have new versions.
|
||||
|
||||
@@ -3522,6 +3543,9 @@ class PollingCollector:
|
||||
shrinks the pending set and must not produce a second notification.
|
||||
"""
|
||||
now = time.time()
|
||||
if self._post_install_startup_reset_pending:
|
||||
self._post_install_startup_reset_pending = False
|
||||
self._reset_post_install_announcements()
|
||||
if now - self._last_post_install_check < self.UPDATE_CHECK_INTERVAL:
|
||||
return
|
||||
self._last_post_install_check = now
|
||||
|
||||
@@ -1608,7 +1608,7 @@ class NotificationManager:
|
||||
showed zero digest entries even when the schedule was firing
|
||||
(issue #233).
|
||||
"""
|
||||
host = _hostname(self._config)
|
||||
host = _resolve_display_hostname(self._config)
|
||||
summary_title = (
|
||||
f"{host}: 24h summary ({now.strftime('%Y-%m-%d %H:%M')})"
|
||||
)
|
||||
@@ -1847,23 +1847,44 @@ class NotificationManager:
|
||||
if not rows:
|
||||
return
|
||||
|
||||
host = _hostname(self._config)
|
||||
host = _resolve_display_hostname(self._config)
|
||||
summary_title = (
|
||||
f"{host}: {len(rows)} events buffered during Quiet Hours"
|
||||
)
|
||||
summary_body = self._compose_digest_body(rows)
|
||||
|
||||
result: dict = {'success': False, 'error': ''}
|
||||
try:
|
||||
channel.send(summary_title, summary_body, severity='INFO',
|
||||
data={'_quiet_hours_summary': True, '_count': len(rows)})
|
||||
result = channel.send(
|
||||
summary_title, summary_body, severity='INFO',
|
||||
data={'_quiet_hours_summary': True, '_count': len(rows)},
|
||||
) or result
|
||||
except Exception as e:
|
||||
print(f"[NotificationManager] quiet send failed for "
|
||||
f"{ch_name}: {e}")
|
||||
return
|
||||
result = {'success': False, 'error': str(e)}
|
||||
|
||||
if result.get('success'):
|
||||
self._stats['total_sent'] += 1
|
||||
self._stats['last_sent_at'] = datetime.now().isoformat()
|
||||
else:
|
||||
self._stats['total_errors'] += 1
|
||||
# Mirrors the digest path: the release is a real delivery, so it
|
||||
# belongs in the history and the counters the operator reads.
|
||||
self._record_history(
|
||||
'quiet_hours', ch_name, summary_title, summary_body, 'INFO',
|
||||
result.get('success', False), result.get('error', '') or '',
|
||||
'quiet_scheduler',
|
||||
)
|
||||
|
||||
# Only drop the rows after a successful send so a transient
|
||||
# transport failure (Telegram timeout, SMTP outage) doesn't
|
||||
# lose the user's overnight context.
|
||||
# lose the user's overnight context. A channel reporting failure
|
||||
# without raising counts as a failure here too — otherwise the
|
||||
# buffer is wiped for a summary the operator never received.
|
||||
if not result.get('success'):
|
||||
return
|
||||
|
||||
try:
|
||||
ids = [r[0] for r in rows]
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||
@@ -2082,6 +2103,7 @@ class NotificationManager:
|
||||
'secure_gateway_update_available',
|
||||
'app_update_available',
|
||||
'docker_stack_update_available',
|
||||
'post_install_update',
|
||||
# Security events that must not be silenced by stale cooldowns
|
||||
# following a Monitor reinstall (Pedro Rico, 19/05).
|
||||
'auth_fail',
|
||||
|
||||
@@ -434,6 +434,29 @@ def scan(persist: bool = True) -> dict[str, Any]:
|
||||
return snapshot
|
||||
|
||||
|
||||
def reset_notified_versions() -> None:
|
||||
"""Forget which optimization versions have already been announced.
|
||||
|
||||
Each version is announced once, so a host that never applies a
|
||||
pending optimization would otherwise stay silent about it forever.
|
||||
Clearing the history reopens that single announcement.
|
||||
|
||||
The caller owns the timing: the notification collector runs this on
|
||||
its first cycle after a service start, together with clearing the
|
||||
matching delivery cooldown, because forgetting the history while the
|
||||
cooldown still suppresses delivery would consume the announcement
|
||||
without ever sending it.
|
||||
"""
|
||||
try:
|
||||
with _cache_lock:
|
||||
scanned_at = float(_cache.get("scanned_at", 0.0) or 0.0)
|
||||
updates = list(_cache.get("updates", []))
|
||||
_write_persisted_snapshot(scanned_at, updates, {})
|
||||
except OSError as e:
|
||||
# Read-only host: de-duplication stays best-effort, as elsewhere.
|
||||
print(f"[post_install_versions] could not reset notified versions: {e}")
|
||||
|
||||
|
||||
def scan_at_startup() -> dict[str, Any]:
|
||||
"""Convenience wrapper called from flask_server startup.
|
||||
|
||||
|
||||
@@ -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