Add audit and reports page, and a change journal

ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer.

The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded.

The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-08 21:06:04 +02:00
co-authored by Claude Opus 5
parent ae75508eff
commit da8a480eff
102 changed files with 24118 additions and 1403 deletions
+323
View File
@@ -0,0 +1,323 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import {
ChevronDown, ChevronRight, FileCode, HelpCircle, Loader2, Package,
Play, Settings2,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
/**
* What ProxMenux changed on this host.
*
* The complaint this answers is not that the tool changes things: it is
* that afterwards nobody can say what it changed. Showing the script
* does not answer it either — a function of four hundred lines may alter
* two values, and the reader cannot tell which two. So what is shown
* here is the difference and nothing else.
*
* Two distinctions are kept in front of the reader, because both bear on
* what they can do about what they are looking at: whether ProxMenux
* authored the change or merely ran something the user asked for, and
* how much of the previous state is actually known.
*/
interface Change {
id: number
recorded_at: number
class: string
operation: string
source: string
function: string
function_version: string
target: string
before_ref: string
after_ref: string
capture: string
revert: string
exactness: string
result: string
recoverable: boolean
detail: Record<string, unknown>
diff?: {
available: boolean; reason?: string
added?: number; removed?: number; truncated?: boolean; hunks?: string[]
} | null
}
interface Summary {
total: number
by_class: Record<string, number>
functions: Array<{
function: string; source: string; version: string
changes: number; last_change: number; first_change: number
}>
journal_started: number | null
}
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof Settings2 }> = {
configuration: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Settings2 },
installation: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: Package },
execution: { chip: "bg-muted text-muted-foreground border-border", Icon: Play },
registration: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
}
export function AuditChanges() {
const t = useT()
const { language } = useI18n()
const [changes, setChanges] = useState<Change[]>([])
const [summary, setSummary] = useState<Summary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState<Set<number>>(new Set())
const [filter, setFilter] = useState<string>("all")
const load = useCallback(async () => {
try {
const res: any = await fetchApi("/api/audit/changes?limit=500")
if (res?.success) {
setChanges(res.changes || [])
setSummary(res.summary || null)
setError(null)
} else {
setError(res?.message || t("audit.changes.failed"))
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => { load() }, [load])
const toggle = (id: number) => setOpen((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const visible = useMemo(
() => changes.filter((c) => filter === "all" || c.class === filter),
[changes, filter],
)
const when = (epoch: number) => new Date(epoch * 1000).toLocaleString(language)
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.changes.loading")}
</div>
)
}
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
return (
<div className="space-y-4">
<Card className="bg-card border-border">
<CardContent className="py-4 space-y-3">
<p className="text-sm text-muted-foreground">{t("audit.changes.intro")}</p>
{/* A host with nothing recorded should say why, rather than
looking like a host nothing has touched. */}
{summary && summary.total === 0 && (
<p className="text-sm text-muted-foreground">{t("audit.changes.empty")}</p>
)}
{summary && summary.journal_started && (
<p className="text-xs text-muted-foreground">
{t("audit.changes.since", { date: when(summary.journal_started) })}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
{(["all", "configuration", "installation", "execution", "registration"] as const)
.filter((key) => key === "all" || summary?.by_class?.[key])
.map((key) => (
<button
key={key}
type="button"
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
filter === key
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`audit.changes.class.${key}`)}
{key !== "all" && summary?.by_class?.[key] !== undefined && (
<span className="ml-1.5 tabular-nums">{summary.by_class[key]}</span>
)}
</button>
))}
</div>
</CardContent>
</Card>
{summary && summary.functions.length > 0 && (
<Card className="bg-card border-border">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<FileCode className="h-4 w-4" />{t("audit.changes.byFunction")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{summary.functions.map((fn) => (
<button
key={fn.function}
type="button"
onClick={() => setFilter("all")}
className="flex w-full flex-wrap items-center gap-2 rounded-md border
border-border p-2.5 text-left hover:bg-white/5
transition-colors cursor-pointer"
>
<span className="font-mono text-sm text-foreground">{fn.function}</span>
{fn.version && (
<Badge variant="outline" className="text-xs">v{fn.version}</Badge>
)}
<Badge variant="outline" className="text-xs tabular-nums">
{t("audit.changes.count", { count: String(fn.changes) })}
</Badge>
<span className="ml-auto text-xs text-muted-foreground">
{when(fn.last_change)}
</span>
</button>
))}
</CardContent>
</Card>
)}
<div className="space-y-2">
{visible.map((change) => {
const style = CLASS_STYLE[change.class] || CLASS_STYLE.registration
const Icon = style.Icon
const expanded = open.has(change.id)
const installed = String(change.detail?.installed || "")
return (
<Card key={change.id} className="bg-card border-border">
<button
type="button"
onClick={() => toggle(change.id)}
aria-expanded={expanded}
className="w-full text-left p-3 flex flex-wrap items-center gap-2
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
{expanded
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className={`${style.chip} gap-1.5 shrink-0`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.changes.operation.${change.operation}`)}
</Badge>
<span className="min-w-0 font-mono text-sm text-foreground break-all">
{change.target}
</span>
{change.diff?.available && (
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
+{change.diff.added} {change.diff.removed}
</Badge>
)}
{/* Whether the previous state is known is what decides
if undoing this is even discussable. */}
{change.capture === "unknown" && (
<Badge variant="outline" className="text-xs shrink-0">
{t("audit.changes.capture.unknown")}
</Badge>
)}
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{when(change.recorded_at)}
</span>
</button>
{expanded && (
<CardContent className="pt-0 pl-10 space-y-3">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>{t("audit.changes.function")}:{" "}
<span className="font-mono text-foreground">{change.function || "—"}</span>
{change.function_version && ` v${change.function_version}`}
</span>
<span>{t("audit.changes.source")}:{" "}
<span className="font-mono">{change.source || "—"}</span></span>
<span>{t("audit.changes.reversibility")}:{" "}
{t(`audit.changes.exactness.${change.exactness}`)}</span>
</div>
{installed && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.packagesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{installed.split(/\s+/).filter(Boolean).map((pkg) => (
<Badge key={pkg} variant="outline" className="font-mono text-xs">
{pkg}
</Badge>
))}
</div>
</div>
)}
{change.class === "execution" && Boolean(change.detail?.command) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.commandRun")}
</p>
<pre className="text-xs font-mono bg-background border border-border
rounded-md p-2.5 overflow-x-auto">
{String(change.detail.command)}
</pre>
<p className="text-xs text-muted-foreground mt-1">
{t("audit.changes.executionNote")}
</p>
</div>
)}
{change.diff && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.difference")}
</p>
{change.diff.available ? (
<>
<pre className="text-xs font-mono bg-background border border-border
rounded-md p-2.5 overflow-x-auto">
{(change.diff.hunks || []).map((line: string, i: number) => (
<div key={i} className={
line.startsWith("+") ? "text-green-500"
: line.startsWith("-") ? "text-red-400"
: line.startsWith("@@") ? "text-blue-400" : ""
}>{line}</div>
))}
</pre>
{change.diff.truncated && (
<p className="text-xs text-muted-foreground mt-1">
{t("audit.changes.diffTruncated")}
</p>
)}
</>
) : (
<p className="text-xs text-muted-foreground">
{t("audit.changes.diffUnavailable")}
</p>
)}
</div>
)}
{change.capture === "unknown" && (
<p className="text-xs text-muted-foreground">
{t("audit.changes.unknownNote")}
</p>
)}
</CardContent>
)}
</Card>
)
})}
{visible.length === 0 && summary && summary.total > 0 && (
<p className="text-sm text-muted-foreground px-1">{t("audit.changes.noneInFilter")}</p>
)}
</div>
</div>
)
}
+241
View File
@@ -0,0 +1,241 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import {
ChevronDown, ChevronRight, Flag, Loader2, MinusCircle,
PlusCircle, ShieldOff, TrendingUp,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
/**
* How this assessment differs from an earlier one.
*
* A single run says what the host is like now. It cannot say whether
* that is better or worse than last week, which is the question anyone
* maintaining a machine actually asks — and the one that turns an audit
* from a snapshot into a record.
*
* The distinction the engine draws and this view keeps: a finding that
* stopped being reported because the host was fixed is not the same as
* one that stopped because somebody accepted it. Both leave the list;
* only the first is progress, and merging them would tell the reader a
* problem went away when the decision was to live with it.
*
* It sits inside the assessment rather than in a view of its own,
* because "what changed since last time" is context for the run being
* read, not a separate place to visit.
*/
interface Finding {
check_id: string
area: string
classification: string
}
interface Comparison {
from: string
to: string
new: Finding[]
resolved: Finding[]
accepted: Finding[]
unchanged: Finding[]
retired: Finding[]
unverified: Finding[]
}
const GROUPS = [
{ key: "new", Icon: PlusCircle, tone: "text-amber-500" },
{ key: "resolved", Icon: MinusCircle, tone: "text-green-500" },
{ key: "accepted", Icon: ShieldOff, tone: "text-indigo-400" },
{ key: "retired", Icon: Flag, tone: "text-muted-foreground" },
] as const
export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
runId: string
isBaseline: boolean
onBaselineSet: () => void
}) {
const t = useT()
const { language } = useI18n()
const [comparison, setComparison] = useState<Comparison | null>(null)
const [baseline, setBaseline] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [open, setOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
// fetchApi turns a non-2xx response into an Error whose message is the
// backend's own English prose and whose `body` carries the parsed
// payload. Both paths therefore go through here: only the reason code
// crosses into a view that exists in eight languages.
const reason = (source: any): string => {
const code = String(source?.reason ?? source?.body?.reason ?? "")
const key = `audit.comparison.reasons.${code}`
const translated = t(key)
return translated !== key ? translated : t("audit.comparison.failed")
}
const load = useCallback(async () => {
setLoading(true)
try {
// Asked for separately: a host with a single run answers the
// comparison with an error, and inside a Promise.all that error
// takes the status with it — leaving no way to tell a first
// assessment from a comparison that genuinely failed, which is
// how "two runs are required" reached a reader who had simply
// never chosen a reference.
const status: any = await fetchApi("/api/audit/status").catch(() => null)
setBaseline(status?.baseline || null)
// With no reference chosen there is nothing to compare against,
// and asking anyway answered 400 — an error in the browser console
// for the ordinary state of a host assessed for the first time.
let diff: any = null
let failure: unknown = null
if (status?.baseline) {
try {
diff = await fetchApi(`/api/audit/compare?to=${encodeURIComponent(runId)}`)
} catch (e) {
failure = e
}
}
setComparison(diff?.success ? diff : null)
// Having no reference run yet is the ordinary state of a host
// assessed for the first time, and the view already says so.
const failed = failure ?? (diff?.success === false ? diff : null)
setError(failed && status?.baseline ? reason(failed) : null)
} finally {
setLoading(false)
}
}, [runId])
useEffect(() => { load() }, [load])
const markBaseline = async () => {
setSaving(true)
try {
const res: any = await fetchApi("/api/audit/baseline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_id: runId }),
})
if (res?.success) { onBaselineSet(); await load() }
else setError(reason(res))
} catch (e) {
setError(reason(e))
} finally {
setSaving(false)
}
}
if (loading) {
return (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("audit.comparison.loading")}
</p>
)
}
const counts = comparison
? GROUPS.map((g) => ({ ...g, n: (comparison[g.key] || []).length }))
.filter((g) => g.n > 0)
: []
const comparable = comparison && comparison.from !== comparison.to
return (
<div className="space-y-2">
{error && <p className="text-xs text-red-400">{error}</p>}
{!comparable ? (
<p className="text-xs text-muted-foreground">
{baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")}
</p>
) : (
<>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full flex-wrap items-center gap-2 rounded-md -mx-2 px-2 py-1
text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
<TrendingUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{t("audit.comparison.since", {
date: baseline?.started_at
? new Date(baseline.started_at * 1000).toLocaleDateString(language)
: t("audit.comparison.previousRun"),
})}
</span>
{counts.length === 0 ? (
<Badge variant="outline" className="text-xs">
{t("audit.comparison.noChange")}
</Badge>
) : counts.map(({ key, Icon, tone, n }) => (
<Badge key={key} variant="outline" className={`text-xs gap-1.5 ${tone}`}>
<Icon className="h-3 w-3" />
{t(`audit.comparison.${key}`)}
<span className="tabular-nums font-semibold">{n}</span>
</Badge>
))}
</button>
{open && (
<div className="space-y-3 pl-6">
{GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map(
({ key, Icon, tone }) => (
<div key={key}>
<p className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${tone}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.comparison.${key}`)}
<span className="font-normal text-muted-foreground">
{t(`audit.comparison.${key}Note`)}
</span>
</p>
<div className="flex flex-wrap gap-1.5">
{(comparison[key] || []).map((f) => (
<Badge key={f.check_id} variant="outline" className="text-xs">
{t(`audit.checks.${f.check_id}.title`)}
</Badge>
))}
</div>
</div>
),
)}
{(comparison.unchanged || []).length > 0 && (
<p className="text-xs text-muted-foreground">
{t("audit.comparison.unchanged", {
count: String(comparison.unchanged.length),
})}
</p>
)}
</div>
)}
</>
)}
{/* Choosing a reference is what makes every later run comparable,
so the action lives beside the comparison it enables. */}
{!isBaseline && (
<Button
variant="outline"
size="sm"
onClick={markBaseline}
disabled={saving}
className="h-7 text-xs"
>
{saving
? <Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
: <Flag className="h-3 w-3 mr-1.5" />}
{t("audit.comparison.setBaseline")}
</Button>
)}
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
"use client"
import { parseEvidence, type EvidenceBlock } from "../lib/evidence-format"
/**
* Renders a finding's evidence as tables and labelled values.
*
* The evidence exists so a reader can verify the conclusion for
* themselves. A JSON dump technically contains the same facts but asks
* the reader to parse it first, which is the part they came here to
* avoid.
*/
export function AuditEvidence({ evidence, locale }: {
evidence: string | null
locale: string
}) {
const blocks = parseEvidence(evidence, locale)
if (blocks.length === 0) return null
return (
<div className="space-y-3">
{blocks.map((block, i) => <Block key={i} block={block} />)}
</div>
)
}
function Block({ block }: { block: EvidenceBlock }) {
const title = block.title
? <p className="text-xs font-medium text-foreground mb-1">{block.title}</p>
: null
if (block.kind === "table") {
return (
<div>
{title}
{/* Wide evidence scrolls inside its own box so the page itself
never scrolls sideways. */}
{/* Evidence is the widest thing on the page; on a narrow screen
it stacks like the rest rather than scrolling sideways. */}
<div className="sm:hidden space-y-2">
{block.rows.map((row, i) => (
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.map((cell, j) => cell === "—" || cell === "" ? null : (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">
{block.columns[j]}</span>
<span className="text-xs text-foreground tabular-nums break-words min-w-0">
{cell}</span>
</div>
))}
</div>
))}
</div>
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/50">
{block.columns.map((c) => (
<th key={c} className="text-left font-medium px-3 py-1.5
text-muted-foreground whitespace-nowrap">
{c}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i} className="border-t border-border">
{row.map((cell, j) => (
<td key={j} className="px-3 py-1.5 align-top tabular-nums">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
if (block.kind === "pairs") {
return (
<div>
{title}
<dl className="grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1 text-xs
rounded-md border border-border px-3 py-2">
{block.entries.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-muted-foreground whitespace-nowrap">{label}</dt>
<dd className="text-foreground break-words tabular-nums">{value}</dd>
</div>
))}
</dl>
</div>
)
}
return (
<div>
{title}
{block.lines.length > 0 && (
<ul className="text-xs text-muted-foreground space-y-0.5 rounded-md
border border-border px-3 py-2">
{block.lines.map((line, i) => (
<li key={i} className="break-words">{line}</li>
))}
</ul>
)}
</div>
)
}
@@ -0,0 +1,34 @@
"use client"
import { presentFinding, type PresentedFinding, type AuditTranslate } from "../lib/audit-presentation"
export function AuditFindingData({ finding, t, locale }: {
finding: PresentedFinding; t: AuditTranslate; locale: string
}) {
return <div className="space-y-4">{presentFinding(finding, t, locale).map((group, index) =>
<section key={index}>
<p className="text-sm font-medium mb-2">{group.title}</p>
{group.note && <p className="text-sm text-muted-foreground mb-2">{group.note}</p>}
{/* Wide on a screen that has the width; stacked where it does
not, so a heading stays beside the value it belongs to instead
of scrolling away from it. */}
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground"><tr>{group.columns.map((column, i) =>
<th key={i} className="px-3 py-2 text-left font-medium">{column}</th>)}</tr></thead>
<tbody>{group.rows.map((row, i) => <tr key={i} className="border-t border-border">
{row.cells.map((cell, j) => <td key={j} className="px-3 py-2 align-top break-words tabular-nums">{cell}</td>)}
</tr>)}</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">{group.rows.map((row, i) =>
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.cells.map((cell, j) => cell === "" || cell === "—" ? null : (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">{group.columns[j]}</span>
<span className="text-sm text-foreground tabular-nums break-words min-w-0">{cell}</span>
</div>
))}
</div>)}</div>
</section>)}</div>
}
+760
View File
@@ -0,0 +1,760 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import {
Activity, Boxes, ChevronDown, ChevronRight, CircuitBoard, Cpu, HardDrive,
Loader2, MemoryStick, Network, Package, Plug, Server, Share2, Wrench,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
import { subscriptionLabel } from "../lib/audit-presentation"
interface UplinkHop { kind: string; id: string; mode?: string; role?: string }
interface GuestDisk {
slot: string; storage: string | null; volume: string
size: string; passthrough?: boolean
}
interface GuestNic {
slot: string; name: string; bridge: string; mac: string; vlan: string
uplink: UplinkHop[] | null
}
interface GuestBackup { job: string; storage: string; schedule: string; retention: string }
interface Guest {
vmid: number; type: string; name: string; cores: string; memory: string
ostype: string; onboot: boolean; tags: string; protected: boolean
unprivileged: boolean | null; features: string | null
agent: boolean | null; cpu: string | null
disks: GuestDisk[]; interfaces: GuestNic[]; backups: GuestBackup[]
}
interface Inventory {
collected_at: number
node: string
unavailable: Record<string, string>
sections: {
identity?: Record<string, string | null>
cluster?: any
hardware?: any
storages?: any[]
guests?: Guest[]
passthrough?: any[]
applications?: any[]
custom_links?: any[]
proxmenux?: { optimizations: any[]; pending_updates: any[] }
network?: { bridges: Record<string, any> } | null
latency?: { window: string; targets: any[] } | null
}
}
const GiB = 1024 ** 3
function bytes(value: number | null | undefined): string {
if (!value || value <= 0) return "—"
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
let n = value, i = 0
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}`
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === "") return null
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm text-foreground break-words">{value}</p>
</div>
)
}
/**
* One table shape for the whole view.
*
* The inventory is read across as much as down — a disk's model beside
* its bus beside its wear — so the sections that enumerate things share
* a single table rather than each inventing its own row layout.
*
* On a narrow screen the same rows are stacked instead. A disk table is
* eight columns wide; sideways scrolling technically fits it on a phone,
* but reading a row then means dragging back and forth to pair each
* value with its heading. Stacked, the heading travels with the value.
*/
function DataTable({ columns, rows }: {
columns: string[]
rows: React.ReactNode[][]
}) {
if (rows.length === 0) return null
return (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
{columns.map((c, i) => (
<th key={i} className="pb-2 pr-4 font-medium whitespace-nowrap">{c}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-t border-border">
{row.map((cell, j) => (
<td key={j} className="py-2 pr-4 align-top tabular-nums">{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{rows.map((row, i) => (
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.map((cell, j) => {
// A cell with nothing in it would leave a heading standing
// alone, which reads as missing data rather than as absent.
if (cell === null || cell === undefined || cell === "" || cell === "—") return null
return (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">{columns[j]}</span>
<span className="text-sm text-foreground tabular-nums break-words min-w-0">
{cell}
</span>
</div>
)
})}
</div>
))}
</div>
</>
)
}
function Mono({ children }: { children: React.ReactNode }) {
return <span className="font-mono text-xs text-muted-foreground">{children}</span>
}
function Section({
icon, title, count, children, note,
}: {
icon: React.ReactNode; title: string; count?: number
children: React.ReactNode; note?: string
}) {
const [open, setOpen] = useState(true)
return (
<Card className="bg-card border-border">
<CardHeader className="pb-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 rounded-md -mx-2 -my-1 px-2 py-1
text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<CardTitle className="flex min-w-0 items-center gap-2 text-base font-semibold text-foreground">
{icon}<span className="min-w-0 break-words">{title}</span>
</CardTitle>
{count !== undefined && (
<Badge variant="outline" className="ml-auto shrink-0 text-xs tabular-nums">{count}</Badge>
)}
</button>
</CardHeader>
{open && (
<CardContent className="pt-0 space-y-3">
{note && <p className="text-xs text-muted-foreground">{note}</p>}
{children}
</CardContent>
)}
</Card>
)
}
/** A heading inside a section, matching the printed document's. */
function Sub({ icon, title, note }: {
icon: React.ReactNode; title: string; note?: string
}) {
return (
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-foreground mt-4 mb-1 first:mt-0">
{icon}{title}
{note && <span className="font-normal text-muted-foreground"> {note}</span>}
</p>
)
}
// The uplink is the point of the network section: a bridge on its own
// says nothing, the path from it to the wire is what an operator needs.
function Uplink({ hops }: { hops: UplinkHop[] | null }) {
const t = useT()
if (hops === null) {
return <span className="text-xs text-muted-foreground italic">{t("audit.inventory.unresolved")}</span>
}
if (hops.length === 0) {
return <span className="text-xs text-muted-foreground">{t("audit.inventory.noUplink")}</span>
}
return (
<span className="flex flex-wrap items-center gap-1 text-xs">
{hops.map((h, i) => (
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground"></span>}
<Badge variant="outline" className="font-mono text-xs">
{h.id}{h.mode ? ` · ${h.mode}` : ""}
</Badge>
</span>
))}
</span>
)
}
export function AuditInventory({ profile = "full" }: { profile?: string }) {
const t = useT()
const { language } = useI18n()
const [data, setData] = useState<Inventory | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openGuest, setOpenGuest] = useState<Set<number>>(new Set())
const load = useCallback(async () => {
try {
const res: any = await fetchApi(
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
if (res?.success) { setData(res.inventory); setError(null) }
else setError(res?.message || t("audit.inventory.failed"))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally { setLoading(false) }
}, [t, profile])
useEffect(() => { load() }, [load])
const apps = useMemo(() => {
const byGuest = new Map<number, any[]>()
for (const a of data?.sections.applications || []) {
if (!byGuest.has(a.vmid)) byGuest.set(a.vmid, [])
byGuest.get(a.vmid)!.push(a)
}
return byGuest
}, [data])
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.inventory.loading")}
</div>
)
}
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
if (!data) return null
const s = data.sections
const hw = s.hardware || {}
const mem = hw.memory || {}
const when = (value: number | string | null | undefined) => {
if (!value) return "—"
const date = typeof value === "number"
? new Date(value * 1000)
: new Date(/[Z+]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value}Z`)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(language)
}
const toggle = (vmid: number) => setOpenGuest((prev) => {
const next = new Set(prev)
next.has(vmid) ? next.delete(vmid) : next.add(vmid)
return next
})
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground px-1">
{t("audit.inventory.collectedAt", {
when: new Date(data.collected_at * 1000).toLocaleString(language),
})}
</p>
{/* Sections that could not be read are named, so an empty list is
never mistaken for a section that was read and found nothing. */}
{Object.keys(data.unavailable || {}).length > 0 && (
<Card className="bg-card border-border">
<CardContent className="py-3 space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{t("audit.inventory.unavailable")}
</p>
{Object.entries(data.unavailable).map(([k, v]) => (
<p key={k} className="text-xs text-muted-foreground">
<span className="font-mono">{k}</span> {v}
</p>
))}
</CardContent>
</Card>
)}
{s.identity && (
<Section icon={<Server className="h-4 w-4 text-blue-500" />} title={t("audit.inventory.identity")}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("audit.inventory.node")} value={s.identity?.node} />
<Field label={t("audit.inventory.pveVersion")} value={s.identity?.pve_version} />
<Field label={t("audit.inventory.kernel")} value={s.identity?.kernel} />
<Field label={t("audit.inventory.subscription")} value={subscriptionLabel(t, s.identity?.subscription)} />
<Field label={t("audit.inventory.cluster")}
value={s.identity?.cluster || t("audit.inventory.standalone")} />
<Field label={t("audit.document.system")}
value={[hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ")} />
</div>
</Section>
)}
{"cluster" in s && (
<Section icon={<Share2 className="h-4 w-4 text-cyan-500" />} title={t("audit.document.cluster")}
count={s.cluster ? (s.cluster.nodes || []).length : undefined}
note={s.cluster ? undefined : t("audit.document.standaloneNote")}>
{s.cluster && (
<>
<div className="grid gap-3 sm:grid-cols-3">
<Field label={t("audit.inventory.cluster")} value={s.cluster.name} />
<Field label={t("audit.document.quorum")}
value={s.cluster.quorate == null ? "—" : (
<Badge variant="outline" className={s.cluster.quorate
? "bg-green-500/10 text-green-500 border-green-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20"}>
{t(s.cluster.quorate ? "audit.document.quorate"
: "audit.document.inquorate")}
</Badge>
)} />
<Field label={t("audit.document.votes")}
value={`${s.cluster.total_votes ?? "—"} / ${s.cluster.expected_votes ?? "—"}`} />
</div>
<DataTable
columns={[t("audit.document.nodeName"), "nodeid", "ring0", "ring1",
t("audit.document.state")]}
rows={(s.cluster.nodes || []).map((n: any) => [
<span className="font-medium text-foreground">
{n.name}
{n.local && <span className="text-muted-foreground">
{" "}({t("audit.document.thisNode")})</span>}
</span>,
<Mono>{n.nodeid || "—"}</Mono>,
<Mono>{n.ring0_addr || "—"}</Mono>,
<Mono>{n.ring1_addr || "—"}</Mono>,
n.online === false
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
{t("audit.document.unreachable")}</Badge>
: n.online === true
? <Badge variant="outline">{t("audit.document.member")}</Badge>
: "—",
])}
/>
</>
)}
</Section>
)}
{s.hardware && (
<Section icon={<Cpu className="h-4 w-4 text-indigo-500" />} title={t("audit.document.architecture")}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("audit.inventory.cpu")} value={hw.cpu?.model} />
<Field label={t("audit.inventory.topology")}
value={hw.cpu ? t("audit.inventory.cpuLayout", {
sockets: String(hw.cpu.sockets), cores: String(hw.cpu.cores_per_socket),
threads: String(hw.cpu.threads),
}) : ""} />
<Field label={t("audit.inventory.memory")}
value={hw.memory_bytes ? `${(hw.memory_bytes / GiB).toFixed(0)} GiB` : ""} />
<Field label={t("audit.inventory.virtualisation")} value={hw.cpu?.virtualisation} />
<Field label={t("audit.inventory.serial")} value={hw.system?.serial} />
<Field label={t("audit.document.board")}
value={[hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ")} />
<Field label={t("audit.inventory.bios")}
value={[hw.bios?.vendor, hw.bios?.version, hw.bios?.date].filter(Boolean).join(" · ")} />
<Field label={t("audit.inventory.iommuGroups")} value={hw.iommu_groups} />
</div>
{(mem.modules || []).length > 0 && (
<>
<Sub icon={<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.memoryModules")}
note={t("audit.document.slotsFilled", {
used: String(mem.populated ?? 0),
total: String(mem.slots ?? mem.populated ?? 0),
})} />
<DataTable
columns={[t("audit.document.slot"), t("audit.document.size"),
t("audit.document.type"), t("audit.document.formFactor"),
t("audit.document.speed"), t("audit.document.manufacturer")]}
rows={(mem.modules || []).map((m: any) => [
<Mono>{m.locator || "—"}</Mono>, m.size || "—", m.type || "—",
m.form_factor || "—", m.speed || "—",
[m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—",
])}
/>
</>
)}
{(hw.controllers || []).length > 0 && (
<>
<Sub icon={<CircuitBoard className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.controllers")} />
<DataTable
columns={["PCI", t("audit.document.class"), t("audit.document.device")]}
rows={(hw.controllers || []).map((c: any) => [
<Mono>{c.slot}</Mono>, c.class, c.name,
])}
/>
</>
)}
</Section>
)}
{(hw.disks || []).length > 0 && (
<Section icon={<HardDrive className="h-4 w-4 text-amber-500" />} title={t("audit.document.storageDevices")}
count={(hw.disks || []).length}>
<DataTable
columns={[t("audit.document.device"), t("audit.document.model"),
t("audit.document.serial"), t("audit.document.size"),
t("audit.document.bus"), "SMART", t("audit.document.serviceLife"),
t("audit.document.events")]}
rows={(hw.disks || []).map((d: any) => {
const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase())
return [
<span className="font-medium text-foreground">{d.name}</span>,
d.model || "—",
<Mono>{d.serial || "—"}</Mono>,
bytes(d.size_bytes),
`${(d.bus || "—").toUpperCase()} · ${d.rotational ? "HDD" : "SSD"}`,
ok
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("audit.document.healthy")}</Badge>
: d.health && d.health !== "unknown"
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
{d.health}</Badge>
: "—",
typeof d.power_on_hours === "number" && d.power_on_hours > 0
? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) })
: "—",
(d.observations || []).length
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
{d.observations.length}</Badge>
: <span className="text-muted-foreground"></span>,
]
})}
/>
{(hw.disks || []).some((d: any) => (d.observations || []).length > 0) && (
<>
<Sub icon={<Activity className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.observations")}
note={t("audit.document.observationsNote")} />
{(hw.disks || []).filter((d: any) => (d.observations || []).length).map((d: any) => (
<div key={d.name} className="mt-2">
<p className="text-xs font-medium text-foreground mb-1">
{d.name} <span className="font-normal text-muted-foreground">{d.model}</span>
</p>
<DataTable
columns={[t("audit.document.event"), t("audit.document.severity"),
t("audit.document.occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), t("audit.document.detail")]}
rows={d.observations.map((o: any) => [
o.type || "—",
<Badge variant="outline" className={o.severity === "critical"
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-amber-500/10 text-amber-500 border-amber-500/20"}>
{o.severity
? t(`audit.classifications.${
o.severity === "critical" ? "critical" : "warning"}`)
: "—"}</Badge>,
String(o.count ?? "—"),
when(o.first_seen), when(o.last_seen),
<span className="text-muted-foreground break-words">{o.message || ""}</span>,
])}
/>
</div>
))}
</>
)}
</Section>
)}
{(s.network || (hw.adapters || []).length > 0) && (
<Section icon={<Network className="h-4 w-4 text-green-500" />} title={t("audit.inventory.network")}
count={Object.keys(s.network?.bridges || {}).length || undefined}>
{(hw.adapters || []).length > 0 && (
<>
<Sub icon={<Plug className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.physicalAdapters")} />
<DataTable
columns={[t("audit.document.interface"), t("audit.document.state"),
t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"]}
rows={(hw.adapters || []).map((a: any) => [
<span className="font-medium text-foreground">{a.name}</span>,
a.state === "up"
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{a.state}</Badge>
: <Badge variant="outline">{a.state || "—"}</Badge>,
a.speed_mbps
? (a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`)
: "—",
<Mono>{a.mac || "—"}</Mono>, a.driver || "—", <Mono>{a.pci || "—"}</Mono>,
])}
/>
</>
)}
{s.network && (
<>
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.bridges")} />
<div className="space-y-2">
{Object.entries(s.network.bridges || {}).map(([id, b]: [string, any]) => (
<div key={id} className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant="outline" className="font-mono">{id}</Badge>
<span className="text-muted-foreground"></span>
<Uplink hops={b.uplink} />
{b.vlan_interface && (
<Badge variant="outline" className="text-xs">VLAN {b.vlan_interface}</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{(s.guests || []).filter((g) =>
(g.interfaces || []).some((n) => n.bridge === id)).length}
{" "}{t("audit.inventory.guests").toLowerCase()}
</span>
</div>
))}
</div>
</>
)}
</Section>
)}
{s.latency?.targets?.length ? (
<Section icon={<Activity className="h-4 w-4 text-sky-500" />} title={t("audit.document.latency")}
note={t("audit.document.latencyNote")}>
<DataTable
columns={[t("audit.document.target.label"), t("audit.document.minimum"),
t("audit.document.average"), t("audit.document.maximum"),
t("audit.document.packetLoss"), t("audit.document.samples")]}
rows={s.latency.targets.map((target: any) => [
<span className="font-medium text-foreground">
{t(`audit.document.target.${target.target}`)}</span>,
target.min_ms != null ? `${target.min_ms} ms` : "—",
target.avg_ms != null ? `${target.avg_ms} ms` : "—",
target.max_ms != null ? `${target.max_ms} ms` : "—",
target.packet_loss != null ? `${target.packet_loss} %` : "—",
String(target.samples),
])}
/>
</Section>
) : null}
{s.storages && (
<Section icon={<HardDrive className="h-4 w-4 text-purple-500" />} title={t("audit.inventory.storage")}
count={(s.storages || []).length}>
<DataTable
columns={[t("audit.inventory.name"), t("audit.inventory.type"),
t("audit.inventory.content"), t("audit.inventory.shared"),
t("audit.document.location"), t("audit.inventory.guests")]}
rows={(s.storages || []).map((st: any) => [
<Mono>{st.id}</Mono>, st.type,
<span className="text-muted-foreground text-xs">{st.content}</span>,
st.shared
? <Badge variant="outline">{t("audit.inventory.yes")}</Badge>
: <span className="text-muted-foreground"></span>,
<span className="text-muted-foreground text-xs break-all">
{st.server || st.path || "—"}</span>,
String((s.guests || []).filter((g) =>
(g.disks || []).some((d) => d.storage === st.id)).length),
])}
/>
</Section>
)}
{s.guests && (
<Section icon={<Boxes className="h-4 w-4 text-emerald-500" />} title={t("audit.inventory.guests")}
count={(s.guests || []).length}>
<div className="space-y-2">
{(s.guests || []).map((g) => {
const open = openGuest.has(g.vmid)
const guestApps = apps.get(g.vmid) || []
return (
<div key={g.vmid} className="rounded-md border border-border">
<button
type="button"
onClick={() => toggle(g.vmid)}
aria-expanded={open}
className="flex w-full flex-wrap items-center gap-2 rounded-md p-3 text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className="font-mono text-xs">{g.vmid}</Badge>
<span className="font-medium text-foreground">{g.name || "—"}</span>
<Badge variant="outline" className="text-xs uppercase">{g.type}</Badge>
{/* Whether a guest needs a backup is not visible from
here, so its absence is stated, not flagged. */}
{g.backups.length === 0 && (
<Badge variant="outline" className="text-xs">
{t("audit.inventory.noBackup")}
</Badge>
)}
<span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
{g.cores}c · {g.memory}MB
</span>
</button>
{open && (
<div className="border-t border-border p-3 space-y-4">
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Field label={t("audit.inventory.ostype")} value={g.ostype} />
<Field label={t("audit.inventory.onboot")}
value={g.onboot ? t("audit.inventory.yes") : t("audit.inventory.no")} />
<Field label={t("audit.inventory.tags")} value={g.tags} />
{g.type === "lxc" && (
<Field label={t("audit.inventory.privilege")}
value={g.unprivileged ? t("audit.inventory.unprivileged")
: t("audit.inventory.privileged")} />
)}
{g.type === "lxc" && <Field label={t("audit.inventory.features")} value={g.features} />}
{g.type === "qemu" && (
<Field label={t("audit.inventory.agent")}
value={g.agent ? t("audit.inventory.yes") : t("audit.inventory.no")} />
)}
{g.type === "qemu" && <Field label={t("audit.inventory.cpuModel")} value={g.cpu} />}
</div>
{g.disks.length > 0 && (
<div>
<Sub icon={<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.disks")} />
<DataTable
columns={[t("audit.document.slot"), t("audit.document.storage"),
t("audit.inventory.name"), t("audit.document.size")]}
rows={g.disks.map((d) => [
<Mono>{d.slot}</Mono>,
d.storage
? <Badge variant="outline" className="font-mono text-xs">{d.storage}</Badge>
: <Badge variant="outline" className="text-xs">
{t("audit.inventory.passthrough")}</Badge>,
<span className="font-mono text-xs break-all">{d.volume}</span>,
d.size || "—",
])}
/>
</div>
)}
{g.interfaces.length > 0 && (
<div>
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.interfaces")} />
<div className="space-y-1">
{g.interfaces.map((n) => (
<div key={n.slot} className="flex flex-wrap items-center gap-1.5 text-xs">
<Badge variant="outline" className="font-mono">{n.slot}</Badge>
<span className="text-muted-foreground"></span>
<Badge variant="outline" className="font-mono">{n.bridge}</Badge>
<span className="text-muted-foreground"></span>
<Uplink hops={n.uplink} />
{n.vlan && <Badge variant="outline">VLAN {n.vlan}</Badge>}
{n.mac && <Mono>{n.mac}</Mono>}
</div>
))}
</div>
</div>
)}
<div>
<Sub icon={<Package className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.protection")} />
{g.backups.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("audit.inventory.noBackupDetail")}</p>
) : (
<DataTable
columns={[t("audit.document.backup"), t("audit.document.storage"),
t("audit.inventory.type"), t("audit.document.content")]}
rows={g.backups.map((b) => [
<Mono>{b.job}</Mono>,
<Badge variant="outline" className="font-mono text-xs">{b.storage}</Badge>,
b.schedule || "—", b.retention || "—",
])}
/>
)}
</div>
{guestApps.length > 0 && (
<div>
<Sub icon={<Wrench className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.applications")} />
<div className="space-y-1">
{guestApps.map((a: any, i: number) => (
<div key={`${a.slug}-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-foreground">{a.name}</span>
<Mono>{a.version || t("audit.inventory.versionUnknown")}</Mono>
{a.update_available && (
<Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
{a.available}
</Badge>
)}
{(a.ports || []).map((p: any, j: number) => (
<Badge key={j} variant="outline" className="font-mono">
{p.scheme}:{p.port}{p.path}
</Badge>
))}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</Section>
)}
{(s.passthrough || []).length > 0 && (
<Section icon={<Plug className="h-4 w-4 text-violet-500" />} title={t("audit.inventory.passthroughTitle")}
count={(s.passthrough || []).length}>
<DataTable
columns={[t("audit.document.vmid"), t("audit.document.name"),
t("audit.document.slot"), t("audit.document.device"),
t("audit.document.iommuGroup"), t("audit.inventory.sharedGroup")]}
rows={(s.passthrough || []).map((p: any) => [
<Mono>{p.vmid}</Mono>,
p.guest || "—",
<Mono>{p.slot || "—"}</Mono>,
<Mono>{p.address || "—"}</Mono>,
(p.iommu_groups || []).join(", ") || "—",
// Everything in a group moves together, so a shared group is
// what decides whether the passthrough is possible.
(p.shared_group_devices || []).length
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
{p.shared_group_devices.length}</Badge>
: <span className="text-muted-foreground"></span>,
])}
/>
</Section>
)}
{s.proxmenux && (
<Section icon={<Wrench className="h-4 w-4 text-orange-500" />} title={t("audit.inventory.proxmenux")}
count={(s.proxmenux.optimizations || []).length}>
<DataTable
columns={[t("audit.document.name"), t("audit.document.version"),
t("audit.document.state")]}
rows={(s.proxmenux.optimizations || []).map((o: any) => {
const pending = (s.proxmenux!.pending_updates || [])
.find((u: any) => u.key === o.key)
return [
<Mono>{o.key}</Mono>,
o.version || "—",
pending
? <Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
{t("audit.document.updateAvailable", { version: String(pending.available) })}
</Badge>
: <Badge variant="outline">{t("audit.document.current")}</Badge>,
]
})}
/>
</Section>
)}
</div>
)
}
+419
View File
@@ -0,0 +1,419 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Boxes, CheckCircle2, HardDrive, Loader2, Settings2, SlidersHorizontal } from "lucide-react"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
/**
* Declares what is expected of this host.
*
* An assessment can see what the host does; it cannot see what it is
* for. Everything on this page answers a question the host has no way of
* answering itself — does this guest need a backup, must this one come
* back by itself, is this storage essential — and each answer is what
* turns an observation in the report into a warning, or takes it out of
* the count entirely.
*
* Nothing here is required. A host with no declaration produces a
* complete report; it just describes rather than judges.
*/
interface Guest { vmid: number; name: string; type: string }
interface Storage { id: string; type: string }
interface GuestRule {
backup?: string; autostart?: string
recovery_objective_hours?: number; note?: string
}
interface Policy {
guests: Record<string, GuestRule>
storages: Record<string, { role?: string }>
defaults: Record<string, unknown>
thresholds: Record<string, number>
}
interface Vocabulary {
expectations: string[]
roles: string[]
thresholds: Record<string, number>
}
const EMPTY: Policy = { guests: {}, storages: {}, defaults: {}, thresholds: {} }
function PolicySelect({ value, options, prefix, onChange, inherited, inheritedKey, label, disabled }: {
value: string; options: string[]; prefix: string
onChange: (value: string) => void; inherited: string; inheritedKey?: string
label: (key: string) => string; disabled?: boolean
}) {
// One component behind every dropdown on this tab, so it is also the
// one place that decides they look like the rest of the interface.
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full min-w-0 text-foreground sm:w-[12.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">{inherited}</SelectItem>
{/* Declaring here what the default already says would be the same
entry twice, reading the same. */}
{options.filter((option) => option !== inheritedKey).map((option) => (
<SelectItem key={option} value={option}>{label(`${prefix}.${option}`)}</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function AuditPolicy() {
const t = useT()
const [policy, setPolicy] = useState<Policy>(EMPTY)
const [vocabulary, setVocabulary] = useState<Vocabulary | null>(null)
const [guests, setGuests] = useState<Guest[]>([])
const [storages, setStorages] = useState<Storage[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [revision, setRevision] = useState<string | null>(null)
const [conflict, setConflict] = useState(false)
// The declaration is what turns an observation into a warning, so the
// form stays locked until the reader says they are changing it.
const [editing, setEditing] = useState(false)
const locked = !editing || saving || !revision || conflict
const load = useCallback(async () => {
setLoading(true)
setRevision(null)
try {
const [current, inventory]: any[] = await Promise.all([
fetchApi("/api/audit/policy"),
// The declaration is about this host's own guests and storages,
// so they are listed rather than typed in by identifier.
fetchApi("/api/audit/inventory?profile=inventory"),
])
if (current?.success) {
setPolicy({ ...EMPTY, ...current.policy })
setVocabulary(current.vocabulary)
setRevision(current.summary.revision)
setDirty(false); setSaved(false); setConflict(false)
setError(null)
} else {
setError(current?.message || t("audit.policy.failed"))
}
const sections = inventory?.inventory?.sections
setGuests((sections?.guests || []).map((g: any) => ({
vmid: g.vmid, name: g.name, type: g.type,
})))
setStorages((sections?.storages || []).map((s: any) => ({
id: s.id, type: s.type,
})))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => { load() }, [load])
const setGuestRule = (vmid: number, field: keyof GuestRule, value: unknown) => {
setPolicy((prev) => {
const guests = { ...prev.guests }
const rule: GuestRule = { ...(guests[String(vmid)] || {}) }
// Absence inherits; explicit "unspecified" overrides the site default.
if (value === "inherit" || value === "" || value === undefined) {
delete rule[field]
} else {
;(rule as Record<string, unknown>)[field] = value
}
if (Object.keys(rule).length === 0) delete guests[String(vmid)]
else guests[String(vmid)] = rule
return { ...prev, guests }
})
setDirty(true); setSaved(false)
}
const setStorageRole = (id: string, role: string) => {
setPolicy((prev) => {
const storages = { ...prev.storages }
if (role === "inherit" || !role) delete storages[id]
else storages[id] = { role }
return { ...prev, storages }
})
setDirty(true); setSaved(false)
}
const setThreshold = (name: string, raw: string) => {
setPolicy((prev) => {
const thresholds = { ...prev.thresholds }
const value = Number(raw)
if (!raw.trim()) delete thresholds[name]
else thresholds[name] = value
return { ...prev, thresholds }
})
setDirty(true); setSaved(false)
}
const save = async () => {
if (!revision || saving || conflict) return
setSaving(true)
try {
const res: any = await fetchApi("/api/audit/policy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...policy, expected_revision: revision }),
})
if (res?.success) {
setRevision(res.summary.revision)
setDirty(false); setSaved(true); setError(null); setEditing(false)
}
else setError(res?.message || t("audit.policy.failed"))
} catch (e) {
if ((e as { status?: number }).status === 409) {
setConflict(true)
setError(t("audit.policy.conflict"))
} else setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const declared = useMemo(
() => Object.keys(policy.guests).length + Object.keys(policy.storages).length
+ Object.keys(policy.thresholds).length + Object.keys(policy.defaults).length,
[policy],
)
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.policy.loading")}
</div>
)
}
const expectations = vocabulary?.expectations || ["required", "not_required", "unspecified"]
const roles = vocabulary?.roles || ["essential", "optional", "unspecified"]
return (
<form onSubmit={(event) => { event.preventDefault(); void save() }}>
{error && <p className="text-sm text-red-400 px-1">{error}</p>}
{(conflict || !revision) && <Button type="button" onClick={() => void load()}>
{t("audit.policy.reload")}
</Button>}
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardContent className="py-4 space-y-3">
<p className="text-sm text-muted-foreground">{t("audit.policy.intro")}</p>
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline" className="tabular-nums">
{t("audit.policy.declaredCount", { count: String(declared) })}
</Badge>
{saved && <span className="text-sm text-green-500">{t("audit.policy.saved")}</span>}
{editing ? (
<div className="ml-auto flex items-center gap-2">
<button
type="button"
className="h-7 px-3 text-xs rounded-md border border-border bg-background
hover:bg-muted transition-colors text-muted-foreground"
onClick={() => { setEditing(false); void load() }}
disabled={saving}
>
{t("actions.cancel")}
</button>
<button
type="submit"
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white
transition-colors disabled:opacity-50 flex items-center gap-1.5"
disabled={!dirty || saving}
>
{saving
? <Loader2 className="h-3 w-3 animate-spin" />
: <CheckCircle2 className="h-3 w-3" />}
{t("actions.save")}
</button>
</div>
) : (
<button
type="button"
className="ml-auto h-7 px-3 text-xs rounded-md border border-border bg-background
hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => { setEditing(true); setSaved(false) }}
disabled={!revision || conflict}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
</CardContent>
</Card>
<fieldset disabled={locked} className="space-y-4 min-w-0">
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<Boxes className="h-4 w-4" />{t("audit.inventory.guests")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.guestsNote")}</p>
{guests.map((guest) => {
const rule = policy.guests[String(guest.vmid)] || {}
return (
<div key={guest.vmid}
className="rounded-md border border-border p-3 space-y-2
sm:flex sm:flex-wrap sm:items-center sm:gap-3 sm:space-y-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{guest.vmid}
</Badge>
<span className="truncate font-medium text-foreground">
{guest.name || "—"}
</span>
<Badge variant="outline" className="text-xs uppercase shrink-0">
{guest.type}
</Badge>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.backup")}</span>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.backup
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.backup}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.backup ? undefined : "unspecified"}
value={rule.backup || "inherit"}
options={expectations}
prefix="audit.policy.expectation"
onChange={(v) => setGuestRule(guest.vmid, "backup", v)}
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.autostart")}</span>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.autostart
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.autostart}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.autostart ? undefined : "unspecified"}
value={rule.autostart || "inherit"}
options={expectations}
prefix="audit.policy.expectation"
onChange={(v) => setGuestRule(guest.vmid, "autostart", v)}
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.objective")}</span>
<input
type="number"
min={0.000001}
step="any"
inputMode="numeric"
value={rule.recovery_objective_hours ?? ""}
placeholder={policy.defaults.recovery_objective_hours
? String(policy.defaults.recovery_objective_hours) : t("audit.policy.objectivePlaceholder")}
onChange={(e) => setGuestRule(
guest.vmid, "recovery_objective_hours",
e.target.value ? Number(e.target.value) : undefined)}
className="w-full min-w-0 rounded-md border border-border bg-background
px-2 py-1.5 text-sm text-foreground focus:outline-none
focus:ring-1 focus:ring-ring sm:w-24"
/>
{rule.recovery_objective_hours == null && policy.defaults.recovery_objective_hours != null && (
<span>{t("audit.policy.inherit", { value: `${policy.defaults.recovery_objective_hours} ${t("audit.policy.objectivePlaceholder")}` })}</span>
)}
</label>
</div>
)
})}
{guests.length === 0 && (
<p className="text-sm text-muted-foreground">{t("audit.policy.noGuests")}</p>
)}
</CardContent>
</Card>
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<HardDrive className="h-4 w-4" />{t("audit.inventory.storage")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.storagesNote")}</p>
{storages.map((storage) => (
<div key={storage.id}
className="rounded-md border border-border p-3 space-y-2
sm:flex sm:items-center sm:gap-3 sm:space-y-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{storage.id}
</Badge>
<span className="text-sm text-muted-foreground">{storage.type}</span>
</div>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.storage_role
? t("audit.policy.inherit", { value: t(`audit.policy.role.${policy.defaults.storage_role}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.storage_role ? undefined : "unspecified"}
value={policy.storages[storage.id]?.role || "inherit"}
options={roles}
prefix="audit.policy.role"
onChange={(v) => setStorageRole(storage.id, v)}
/>
</div>
))}
</CardContent>
</Card>
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<SlidersHorizontal className="h-4 w-4" />{t("audit.policy.thresholds")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.thresholdsNote")}</p>
<div className="grid gap-2 sm:grid-cols-2">
{Object.entries(vocabulary?.thresholds || {}).map(([name, shipped]) => (
<label key={name}
className="flex flex-col items-end gap-1.5 rounded-md border
border-border p-2.5 sm:flex-row sm:items-center sm:gap-2">
<span className="w-full min-w-0 text-left text-sm text-foreground sm:flex-1">
{t(`audit.policy.threshold.${name}`)}
</span>
<input
type="number"
min={0.000001}
max={name.endsWith("_percent") ? 100 : undefined}
step="any"
inputMode="decimal"
value={policy.thresholds[name] ?? ""}
placeholder={String(shipped)}
onChange={(e) => setThreshold(name, e.target.value)}
className="w-24 shrink-0 rounded-md border border-border bg-background
px-2 py-1.5 text-sm text-foreground tabular-nums
focus:outline-none focus:ring-1 focus:ring-ring"
/>
</label>
))}
</div>
</CardContent>
</Card>
</fieldset>
</form>
)
}
+376 -83
View File
@@ -10,22 +10,42 @@ import {
} from "./ui/dialog"
import {
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
Loader2, MinusCircle, Play, RotateCcw, ShieldOff, XCircle,
FileText, HelpCircle, Info, Loader2, MinusCircle, Play, RotateCcw,
ShieldOff, XCircle,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { AuditInventory } from "./audit-inventory"
import { AuditPolicy } from "./audit-policy"
import { AuditChanges } from "./audit-changes"
import { AuditComparison } from "./audit-comparison"
import { Label } from "./ui/label"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select"
import { unreadSources } from "../lib/audit-presentation"
import { openAuditDocument, openReportWindow } from "../lib/audit-document"
import { AuditEvidence } from "./audit-evidence"
import { AuditFindingData } from "./audit-finding-data"
import { affectedDescription, resultBreakdown } from "../lib/audit-presentation"
import { useI18n } from "../lib/i18n/provider"
interface Finding {
check_id: string
area: string
severity: string
state: string
classification: string
decision?: 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
incomplete?: boolean
collected_at?: number
check_version?: number
sources?: Array<{ source: string; collected_at: number; error?: string }>
exception?: { reason: string; accepted_by: string; accepted_at: number; expires_at?: number | null } | null
}
interface Run {
@@ -35,46 +55,80 @@ interface Run {
finished_at: number | null
status: string
checks_total: number
checks_expected: number
error?: string | null
is_baseline?: number | boolean
// Recorded by the engine: the sources it read and the declaration it
// judged against. The document states the latter in its scope.
metadata?: { policy?: {
declared?: boolean; guests_declared?: number
storages_declared?: number; thresholds_declared?: string[]
} } | null
}
// 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,
//
// One scale, worst first. There is no second ordering by severity any
// more: gravity is the classification, so a finding cannot be a critical
// observation or an informational failure.
const CLASS_RANK: Record<string, number> = {
critical: 0, warning: 1, observation: 2, unverified: 3,
accepted: 4, conformant: 5, not_applicable: 6,
}
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 },
// Only the first two are problems. An observation is drawn in a neutral
// tone on purpose: colouring planning information like a fault is what
// made ordinary configurations read as defects.
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
critical: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
warning: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
observation: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Info },
unverified: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
accepted: { chip: "bg-indigo-500/10 text-indigo-400 border-indigo-400/20", Icon: ShieldOff },
conformant: { 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 },
}
/** What a finding reads as once the reader's decision is applied. */
function shownAs(f: { classification: string; decision?: string }): string {
return f.decision === "accepted" ? "accepted" : f.classification
}
// 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
const SUMMARY_BADGE_CLASS = "h-6 gap-1.5 whitespace-nowrap px-2.5 py-0 text-xs"
export function AuditReport() {
const t = useT()
const { language } = useI18n()
// Assessment and inventory answer different questions and are
// read differently: one is triaged, the other is read through.
const [view, setView] = useState<"assessment" | "inventory" | "changes" | "policy">("assessment")
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 [progress, setProgress] = useState({ completed: 0, total: 0 })
// The profile decides which question the page answers, so it governs
// both what an assessment runs and what the inventory documents.
const [profile, setProfile] = useState("full")
const [profiles, setProfiles] = useState<Array<{ id: string; runs_checks: boolean }>>([])
const [building, setBuilding] = useState(false)
const loadRun = useCallback(async (runId: string) => {
try {
const data: any = await fetchApi(`/api/audit/runs/${runId}`)
const data: any = await fetchApi(`/api/audit/runs/${runId}?effective=1`)
if (data?.success) setFindings(data.findings || [])
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
@@ -86,6 +140,7 @@ export function AuditReport() {
const data: any = await fetchApi("/api/audit/status")
if (!data?.success) return
setRunning(Boolean(data.running))
setProgress({ completed: data.progress?.completed || 0, total: data.progress?.total || 0 })
setSummary(data.summary || {})
setLatest(data.latest || null)
if (data.latest?.run_id) await loadRun(data.latest.run_id)
@@ -97,8 +152,29 @@ export function AuditReport() {
}
}, [loadRun])
useEffect(() => {
fetchApi("/api/audit/profiles")
.then((d: any) => { if (d?.success) setProfiles(d.profiles || []) })
.catch(() => { /* the page works on the default profile */ })
}, [])
useEffect(() => { refresh() }, [refresh])
// Expiry changes a decision, not the assessment. One local timer and
// a focus refresh keep it current without periodic scans or idle polling.
useEffect(() => {
const expiry = findings.flatMap((f) => f.exception?.expires_at ? [f.exception.expires_at] : [])
if (!expiry.length) return
const delay = Math.max(100, Math.min(2147483647, Math.min(...expiry) * 1000 - Date.now() + 100))
const id = setTimeout(refresh, delay)
return () => clearTimeout(id)
}, [findings, refresh])
useEffect(() => {
const onFocus = () => { void refresh() }
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [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(() => {
@@ -112,7 +188,7 @@ export function AuditReport() {
try {
const data: any = await fetchApi("/api/audit/run", {
method: "POST",
body: JSON.stringify({ profile: "full" }),
body: JSON.stringify({ profile }),
})
if (data?.success) setRunning(true)
else setError(data?.message || t("audit.errors.runFailed"))
@@ -130,6 +206,7 @@ export function AuditReport() {
try {
const body: Record<string, unknown> = {
check_id: accepting.check_id,
run_id: latest?.run_id,
reason: reason.trim(),
}
if (expiryDays) body.expires_in_days = Number(expiryDays)
@@ -163,17 +240,20 @@ export function AuditReport() {
[findings],
)
const visible = useMemo(() => {
const quiet = new Set(["pass", "not_applicable"])
return findings
// Every check is listed, worst first. Hiding what passed made the
// reader guess whether a check was clean or had not run, which is
// exactly the distinction this page exists to keep.
const visible = useMemo(() =>
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])
(CLASS_RANK[shownAs(a)] ?? 9) - (CLASS_RANK[shownAs(b)] ?? 9) ||
a.check_id.localeCompare(b.check_id)),
[findings, areaFilter])
const acceptedCount = summary.accepted || 0
const unverifiedChecks = findings.filter(
(f) => f.classification === "unverified" || f.incomplete)
const ageDays = latest?.finished_at
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
: null
@@ -184,6 +264,7 @@ export function AuditReport() {
// correctly under another. A check that failed to evaluate has no
// per-check entry, hence the shared fallback.
const summaryOf = (f: Finding) => {
if (f.check_id === "backup.last_backup_age" && f.affected.length) return resultBreakdown(f, t)
if (!f.summary_key) return ""
const params = Object.fromEntries(
Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]),
@@ -193,6 +274,11 @@ export function AuditReport() {
return text === key ? t("audit.summaryFallback") : text
}
const notApplicableText = (f: Finding) => {
if (f.summary_key) return ""
return f.classification === "not_applicable" ? t("audit.notApplicableScope") : ""
}
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
@@ -210,15 +296,179 @@ export function AuditReport() {
)
}
// The document carries both halves, so the inventory is fetched at the
// moment it is produced rather than kept in memory for a button that
// may never be pressed.
const generateDocument = async () => {
// The window is opened on the click itself, before the inventory is
// fetched, so the popup blocker sees the user gesture. It shows a
// spinner while the document is composed.
const target = openReportWindow(t("audit.document.building"))
setBuilding(true)
try {
const inv: any = await fetchApi(
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
openAuditDocument({
profile,
run: latest,
findings,
inventory: inv?.success ? inv.inventory : null,
t,
locale: language,
}, target)
} catch (e) {
target?.close()
setError(e instanceof Error ? e.message : String(e))
} finally {
setBuilding(false)
}
}
const documentButton = (
<Button
variant="outline"
size="sm"
onClick={generateDocument}
disabled={building}
// Related to running an assessment, and secondary to it: the same
// hue as that button, at the translucent weight the chips use.
// Green is taken — here it means a conformant result, and this
// report may be full of critical ones.
className="shrink-0 border-blue-500/20 bg-blue-500/10 text-blue-500
hover:bg-blue-500/20 hover:text-blue-500"
>
{building
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
: <FileText className="h-4 w-4 mr-2" />}
{/* The icon carries the meaning where the width is short. */}
<span className="sm:hidden">{t("audit.document.actionShort")}</span>
<span className="hidden sm:inline">{t("audit.document.action")}</span>
</Button>
)
const profilePicker = profiles.length > 0 ? (
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm sm:w-auto sm:flex-none">
<Label htmlFor="audit-profile" className="hidden shrink-0 text-muted-foreground sm:inline">
{t("audit.profile.label")}
</Label>
<Select value={profile} onValueChange={setProfile}>
<SelectTrigger id="audit-profile" className="min-w-0 flex-1 sm:w-56 sm:flex-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>{t(`audit.profile.${p.id}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null
const viewTabs = (
<div
role="group"
aria-label={t("audit.viewSwitch.ariaLabel")}
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
sm:inline-flex sm:w-auto"
>
{(["assessment", "inventory", "changes", "policy"] as const).map((key) => (
<button
key={key}
type="button"
aria-pressed={view === key}
onClick={() => setView(key)}
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
view === key
? "bg-blue-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`audit.viewSwitch.${key}`)}
</button>
))}
</div>
)
// Changes and policy carry no profile: one is what was done to this
// host, the other what is expected of it, and neither narrows by report.
if (view === "changes") {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
{viewTabs}
</div>
</div>
<AuditChanges />
</div>
)
}
if (view === "policy") {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
{viewTabs}
</div>
</div>
<AuditPolicy />
</div>
)
}
if (view === "inventory") {
return (
<div className="space-y-4">
{/* A row that cannot wrap has nowhere to put the controls but
beside the title, which then squeezes into two lines. Title
and controls are separate rows until there is width for both. */}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
<div className="flex items-center gap-2 sm:contents">
{profilePicker}{documentButton}
</div>
{viewTabs}
</div>
</div>
<AuditInventory profile={profile} />
</div>
)
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
<div className="flex items-center gap-2 sm:contents">
{profilePicker}{documentButton}
</div>
{viewTabs}
</div>
</div>
<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 ? (
@@ -233,6 +483,29 @@ export function AuditReport() {
</p>
)}
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
{running && <p role="status" className="text-sm text-muted-foreground">
{t("audit.progress", { completed: String(progress.completed), total: String(progress.total) })}
</p>}
{/* A reading that could not be taken is information, not an
alarm: it says the report is narrower than usual, and
colouring it like a finding puts it above warnings the
reader has to act on. A run that failed outright is the
one thing here that does interrupt. */}
{latest && (latest.status === "partial" || latest.status === "failed") && (
<div
{...(latest.status === "failed" ? { role: "alert" as const } : {})}
className={`space-y-1 text-sm ${
latest.status === "failed" ? "text-amber-500" : "text-muted-foreground"
}`}
>
<p>{t(`audit.runStates.${latest.status}`)}</p>
{unverifiedChecks.length > 0 && <p className="text-xs">
{t("audit.unverifiedChecks", { checks: unverifiedChecks
.map((f) => t(`audit.checks.${f.check_id}.title`)).join(" · ") })}
</p>}
</div>
)}
{latest?.error && <p className="text-xs text-amber-500">{latest.error}</p>}
</div>
<Button
@@ -248,21 +521,34 @@ export function AuditReport() {
{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]
{/* One row of counters on one scale. Gravity is the
classification itself, so there is nothing left to
reconcile between two sets of numbers. */}
<div role="group" aria-label={t("audit.results")}
className="flex max-w-full flex-wrap items-center gap-2">
{(["critical", "warning", "observation", "unverified",
"accepted", "conformant", "not_applicable"] as const)
.filter((c) => summary[c])
.map((c) => {
const { chip, Icon } = CLASS_STYLE[c]
return (
<Badge key={s} variant="outline" className={`${chip} gap-1.5`}>
<Badge key={c} variant="outline" className={`${SUMMARY_BADGE_CLASS} ${chip}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.states.${s}`)}
<span className="tabular-nums font-semibold">{summary[s]}</span>
{t(`audit.classifications.${c}`)}
<span className="tabular-nums font-semibold">{summary[c]}</span>
</Badge>
)
})}
</div>
{/* What changed since a reference run: context for the
assessment being read, not a place of its own. */}
<AuditComparison
runId={latest.run_id}
isBaseline={Boolean(latest.is_baseline)}
onBaselineSet={refresh}
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
@@ -289,13 +575,6 @@ export function AuditReport() {
{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
@@ -324,9 +603,10 @@ export function AuditReport() {
<div className="space-y-2">
{visible.map((f) => {
const { chip, Icon } = STATE_STYLE[f.state] || STATE_STYLE.not_applicable
const shown = shownAs(f)
const { chip, Icon } = CLASS_STYLE[shown] || CLASS_STYLE.not_applicable
const open = expanded.has(f.check_id)
const muted = f.state === "accepted" || f.state === "not_applicable"
const muted = shown === "accepted" || shown === "not_applicable"
return (
<Card
key={f.check_id}
@@ -336,14 +616,14 @@ export function AuditReport() {
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"
className="w-full text-left p-4 flex items-start gap-3 rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
{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}`)}
{t(`audit.classifications.${shown}`)}
</Badge>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@@ -353,15 +633,28 @@ export function AuditReport() {
<Badge variant="outline" className="text-xs">
{t(`audit.areas.${f.area}`)}
</Badge>
{f.incomplete && <Badge variant="outline" className="text-xs text-amber-500">
{t("audit.incomplete")}
</Badge>}
{f.affected.length > 0 && (
<Badge variant="outline" className="text-xs tabular-nums">
{t("audit.affectedCount", { count: String(f.affected.length) })}
{affectedDescription(f, t)}
</Badge>
)}
</div>
{f.summary_key && (
{(f.summary_key || notApplicableText(f)) && (
<p className="text-sm text-muted-foreground mt-1">
{summaryOf(f)}
{f.summary_key ? summaryOf(f) : notApplicableText(f)}
</p>
)}
{/* "Could not be evaluated" describes the assessment, not
the host. What could not be read is recorded against
each source, and belongs here rather than two
collapsed panels below. */}
{unreadSources(f.sources, t) && (
<p className="text-xs text-muted-foreground mt-1">
{unreadSources(f.sources, t)}
</p>
)}
</div>
@@ -387,41 +680,42 @@ export function AuditReport() {
<p className="text-xs text-muted-foreground mt-1">
{f.exception.accepted_by} ·{" "}
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
{f.exception.expires_at && <> · {t("audit.expires", {
when: new Date(f.exception.expires_at * 1000).toLocaleString(),
})}</>}
</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>
)}
{/* Some checks carry a useful, structured positive reading
in their evidence even when nothing is affected. The
presenter returns no groups for checks without such a
view, so rendering it unconditionally does not create
empty space. */}
<AuditFindingData finding={f} t={t} locale={language} />
{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>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground mb-2">
{t("audit.presentation.technical")}
</summary>
<AuditEvidence evidence={f.evidence} locale={language} />
</details>
)}
{f.sources && f.sources.length > 0 && <details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">{t("audit.detail.sources")}</summary>
<ul className="mt-2 space-y-1">
{f.sources.map((source) => <li key={source.source} className="break-all">
{source.source} · {new Date(source.collected_at * 1000).toLocaleString()}
{source.error && <span className="text-amber-500"> · {source.error}</span>}
</li>)}
</ul>
</details>}
{/* 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") && (
{(f.classification === "critical" || f.classification === "warning")
&& !f.decision && !f.incomplete && (
<Button
variant="outline"
size="sm"
@@ -431,7 +725,7 @@ export function AuditReport() {
{t("audit.acceptRisk.action")}
</Button>
)}
{f.state === "accepted" && (
{f.decision === "accepted" && (
<Button
variant="outline"
size="sm"
@@ -485,17 +779,16 @@ export function AuditReport() {
<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>
<Select value={expiryDays || "none"}
onValueChange={(v) => setExpiryDays(v === "none" ? "" : v)}>
<SelectTrigger id="audit-expiry" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("audit.acceptRisk.expiryNever")}</SelectItem>
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
+52 -20
View File
@@ -1205,27 +1205,42 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
web_path: link.web_path || "/",
logo_url: link.logo_url || "",
}
const last = draft.ports[draft.ports.length - 1]
const indexAfterAdd = (last && last.port === "" && !last.description)
? draft.ports.length - 1
: draft.ports.length
if (indexAfterAdd === draft.ports.length) {
setField({ ports: [...draft.ports, entry] })
} else {
const ports = [...draft.ports]
ports[indexAfterAdd] = entry
setField({ ports })
}
setDetectorTest(null)
setEditing((current) => {
if (!current) return current
const currentPorts = current.draft.ports
// The buttons disappear after a port is added, but guard the state
// update too so a double click can never create duplicate links.
if (currentPorts.some((port) => port.port === link.host_port)) return current
const ports = [...currentPorts]
const last = ports[ports.length - 1]
if (last && last.port === "" && !last.description) ports[ports.length - 1] = entry
else ports.push(entry)
return { ...current, draft: { ...current.draft, ports } }
})
// Ask the backend whether this service_name has a known catalog
// category and, if so, patch the just-inserted port so the user
// finds it pre-selected instead of having to open the dropdown.
// Non-blocking — the port is already visible either way.
// This must be a functional update: the response can arrive after the
// user has added or edited more links, and must never restore the old
// draft captured by this render.
const q = (link.service_name || "").trim()
if (q) {
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
.then((r) => {
if (!r?.category) return
setPort(indexAfterAdd, { category: r.category })
setEditing((current) => {
if (!current) return current
let changed = false
const ports = current.draft.ports.map((port) => {
if (port.port !== link.host_port || port.category) return port
changed = true
return { ...port, category: r.category || undefined }
})
return changed
? { ...current, draft: { ...current.draft, ports } }
: current
})
})
.catch(() => { /* non-fatal — user can pick manually */ })
}
@@ -1235,15 +1250,32 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const usedPorts = new Set(draft.ports.map((p) => p.port))
const isDockerDraft = draft.helper_slug === "docker" ||
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
// A recognised Docker workload belongs in one place at a time. Keep its
// links out of Docker while it is offered (or already registered) as an
// independent app. Dismissing that detection makes the links available
// under Docker again.
const independentDockerWorkloadSlugs = new Set(
(suggestions?.docker_workloads || [])
.filter((workload) => !dismissedSlugs.has(workload.slug))
.map((workload) => workload.slug),
)
const suggestableDockerLinks = isDockerDraft
? (suggestions?.docker_web_links || []).filter((link) => !usedPorts.has(link.host_port))
? (suggestions?.docker_web_links || []).filter(
(link) =>
!usedPorts.has(link.host_port) &&
(!link.service_slug || !independentDockerWorkloadSlugs.has(link.service_slug)),
)
: []
// A Docker registration uses structured container → published-port
// suggestions below. Suppress the generic ss/netstat chips in that case
// so the same endpoint is not presented twice without its workload name.
const suggestable = isDockerDraft
? []
: (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
// Keep the generic ss/netstat probe available for Docker too. It covers
// host-networked services and listeners that Docker does not expose in
// NetworkSettings.Ports. Published ports already represented by a named
// Docker workload stay deduplicated from the generic chips.
const dockerPublishedPorts = new Set(
(suggestions?.docker_web_links || []).map((link) => link.host_port),
)
const suggestable = (suggestions?.port_suggestions || []).filter(
(port) => !usedPorts.has(port) && (!isDockerDraft || !dockerPublishedPorts.has(port)),
)
return (
<div className="space-y-4">
@@ -778,6 +778,7 @@ export function ProxmoxDashboard() {
<React.Fragment key="admin">
{btn("logs", ScrollText, t("navigation.systemLogs"))}
{btn("security", ShieldCheck, t("navigation.security"))}
{btn("audit", ClipboardCheck, t("navigation.audit"))}
{btn("settings", SettingsIcon, t("navigation.settings"))}
{btn("about", Info, t("navigation.about"))}
</React.Fragment>
+38 -2
View File
@@ -81,6 +81,12 @@ export function Security() {
if (normalized.includes("invalid 2fa code")) {
return st("errors.invalid2faCode")
}
if (normalized.includes("2fa code required")) {
return st("errors.enter2faOrBackup")
}
if (normalized.includes("current password is incorrect")) {
return st("errors.invalidPassword")
}
if (normalized.includes("invalid password")) {
return st("errors.invalidPassword")
}
@@ -103,6 +109,7 @@ export function Security() {
const [currentPassword, setCurrentPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmNewPassword, setConfirmNewPassword] = useState("")
const [changePasswordTotpCode, setChangePasswordTotpCode] = useState("")
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
@@ -976,6 +983,11 @@ export function Security() {
return
}
if (totpEnabled && !changePasswordTotpCode.trim()) {
setError(st("errors.enter2faOrBackup"))
return
}
const pwError = validatePasswordStrength(newPassword, t)
if (pwError) {
setError(pwError)
@@ -992,8 +1004,9 @@ export function Security() {
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
},
body: JSON.stringify({
current_password: currentPassword,
old_password: currentPassword,
new_password: newPassword,
...(totpEnabled ? { totp_code: changePasswordTotpCode.trim() } : {}),
}),
})
@@ -1012,6 +1025,7 @@ export function Security() {
setCurrentPassword("")
setNewPassword("")
setConfirmNewPassword("")
setChangePasswordTotpCode("")
} catch (err) {
setError(err instanceof Error ? err.message : st("errors.changePasswordFailed"))
} finally {
@@ -2012,6 +2026,22 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
</div>
{totpEnabled && (
<div className="space-y-2">
<Label htmlFor="change-password-totp">{st("twoFactor.codeOrBackup")}</Label>
<Input
id="change-password-totp"
type="text"
inputMode="text"
autoComplete="one-time-code"
placeholder={st("twoFactor.codeOrBackupPlaceholder")}
value={changePasswordTotpCode}
onChange={(e) => setChangePasswordTotpCode(e.target.value)}
disabled={loading}
/>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleChangePassword}
@@ -2021,7 +2051,13 @@ ${(report.sections && report.sections.length > 0) ? `
{loading ? st("auth.changing") : st("auth.changePassword")}
</Button>
<Button
onClick={() => setShowChangePassword(false)}
onClick={() => {
setShowChangePassword(false)
setCurrentPassword("")
setNewPassword("")
setConfirmNewPassword("")
setChangePasswordTotpCode("")
}}
variant="outline"
className="flex-1"
disabled={loading}
+30 -49
View File
@@ -10,7 +10,7 @@ import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, ExternalLink, Search, Tag as TagIcon } from 'lucide-react'
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Tag as TagIcon } from 'lucide-react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Checkbox } from "./ui/checkbox"
import { Switch } from "./ui/switch"
@@ -281,17 +281,6 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
return osUpdates + appUpdates + dockerUpdates + delegatedUpdates > 0
}
function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null {
const custom = (port?.custom_url || "").trim()
if (custom) return custom
const rawIp = (vm.ip || "").trim().split("/")[0]
if (!rawIp || rawIp === "DHCP" || !port?.port) return null
const host = rawIp.includes(":") && !rawIp.startsWith("[") ? `[${rawIp}]` : rawIp
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
return `${scheme}://${host}:${port.port}${path}`
}
interface VMConfig {
cores?: number
memory?: number
@@ -5210,26 +5199,31 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const helperKnownNotUpdateable = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !!uc?.helper_updateable_known
const helperUnlisted = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !uc?.helper_updateable_known
// Registration, version tracking and update execution
// are independent capabilities. Every saved app belongs
// in Updates; installed_via only controls whether a
// version state can be shown.
// are independent capabilities. Docker-delegated apps
// already have their complete lifecycle represented by
// the image inventory, so rendering an app section for
// them would duplicate the same update state.
const registeredApps = (selectedVM.app_watches || []).filter(
(a) => !a.managed_oci_app_id,
)
const independentlyUpdatedApps = registeredApps.filter(
(a) => a.update_via !== "docker",
)
const helperSectionDetected = uc?.helper_slug !== "docker"
&& (helperExists || helperKnownNotUpdateable || helperUnlisted || helperInferred)
const helperMatchingApps = registeredApps.filter(
const helperMatchingApps = independentlyUpdatedApps.filter(
(a) => !!a.helper_slug && a.helper_slug === uc?.helper_slug,
)
const helperOnlyApps = helperMatchingApps.filter(
(a) => !a.update_command,
)
// Every registered app gets exactly one Updates
// section. Docker and the CT-wide helper identity use
// their specialised sections; all other registrations
// use the generic section even when installed_via is
// empty (Web Link only) or dpkg/apk is OS-managed.
const appSections = registeredApps.filter((a) => {
// Every independently updated app gets exactly one
// Updates section. Docker and the CT-wide helper
// identity use their specialised sections; all other
// registrations use the generic section even when
// installed_via is empty (Web Link only) or dpkg/apk
// is OS-managed.
const appSections = independentlyUpdatedApps.filter((a) => {
// Docker owns a dedicated section containing
// Engine and image lifecycles. Its command editor
// is rendered there so Docker never appears twice.
@@ -5469,18 +5463,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
return (
<div className="divide-y divide-border/50">
{uc!.packages.map((p) => (
<div key={p.name} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-0.5 sm:gap-2 py-2 text-sm">
<span className="font-mono text-foreground/90 flex items-center gap-2 min-w-0">
<div key={p.name} className="py-2 text-sm min-w-0">
<span className="font-mono text-foreground/90 flex items-start gap-2 min-w-0">
{p.security && (
<Shield className="h-4 w-4 text-green-500 flex-shrink-0" aria-label={t("vmLxc.updates.securityUpdateAria")} />
<Shield className="h-4 w-4 mt-0.5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.updates.securityUpdateAria")} />
)}
<span className="truncate">{p.name}</span>
</span>
<span className="flex items-center gap-1.5 text-muted-foreground flex-shrink-0 font-mono text-xs sm:text-sm">
<span>{p.current || "—"}</span>
<span></span>
<span className="text-foreground">{p.latest}</span>
<span className="break-all" title={p.name}>{p.name}</span>
</span>
<div className={`${p.security ? "pl-6" : ""} mt-1.5 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2 font-mono text-xs sm:text-sm`}>
<span className="text-muted-foreground break-all">{p.current || "—"}</span>
<span className="text-muted-foreground" aria-hidden="true"></span>
<span className="text-foreground break-all">{p.latest}</span>
</div>
</div>
))}
</div>
@@ -5739,14 +5733,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-1.5">
<Package className="h-3.5 w-3.5 flex-shrink-0" />
{image.installed_version ? (
<span>
<span className={image.update_available === false ? "text-green-500" : undefined}>
{t("vmLxc.updates.installedLabel")} {" "}
<code className="text-foreground/80">{image.installed_version}</code>
<code className={image.update_available === false ? "text-green-500" : "text-foreground/80"}>{image.installed_version}</code>
</span>
) : (
<span>
<span className={image.update_available === false ? "text-green-500" : undefined}>
{t("vmLxc.updates.imageInstalledTag")} {" "}
<code className="text-foreground/80">{image.tag}</code>
<code className={image.update_available === false ? "text-green-500" : "text-foreground/80"}>{image.tag}</code>
</span>
)}
</div>
@@ -5848,9 +5842,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const matchApp = helperOnlyApps[0] || null
if (!matchApp || customCmdEditingApp === matchApp.id) return null
const helperSelected = matchApp.update_method === "helper"
const appWebUrl = helperUsesWebUpdater
? buildRegisteredAppUrl(selectedVM, matchApp.ports?.[0])
: null
const helperTracksVersion = !!matchApp.installed_via
const hasUpd = helperTracksVersion && matchApp.update_available === true
const upToD = helperTracksVersion && matchApp.update_available === false && !!matchApp.installed_version
@@ -5959,20 +5950,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)
})()}
{helperUsesWebUpdater && (
<div className="mt-3 space-y-3">
<div className="mt-3">
<p className="text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.updates.adguardWebUpdateOnly")}
</p>
{appWebUrl && (
<div className="flex justify-end">
<Button size="sm" variant="outline" asChild>
<a href={appWebUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-1.5" />
{t("vmLxc.updates.openAdguard")}
</a>
</Button>
</div>
)}
</div>
)}
{!helperExists && !helperUsesWebUpdater && (
+965
View File
@@ -0,0 +1,965 @@
/**
* The Audit & Report document.
*
* Built on the shell the SMART, Lynis and latency reports share, so a
* reader who has seen one of those recognises this one: the same header
* and report identifier, the same numbered sections, the same action bar
* that disappears when the page is printed.
*
* What the document adds is structure. On screen findings are ordered by
* severity because the reader is triaging; on paper the node is
* described first — how it is built, what it connects to, what it holds
* — and only then judged, because a finding about a bridge means little
* to someone who has not been shown the bridge. The diagrams carry the
* relations the inventory resolves: a list of interfaces and a list of
* guests do not say which path a guest's traffic takes to the wire.
*
* The closing section states the scope: what the report covers and what
* it does not. That statement is what makes the document usable as
* evidence rather than a screenshot.
*/
import {
REPORT_CSS_AUDIT, callout, card, esc, grid, heading, openReportWindow,
renderReport, reportId, section, table, writeReport, icon,
} from "./report-shell"
import {
clusterDiagram, findingsChart, latencyChart, networkDiagram,
nodeArchitectureDiagram, storageDiagram,
} from "./report-diagrams"
import { parseEvidence } from "./evidence-format"
import { presentFinding, auditInstant, auditLabel, resultBreakdown, unreadSources, subscriptionLabel } from "./audit-presentation"
type Translate = (key: string, params?: Record<string, string>) => string
export interface DocumentInput {
profile: string
run: {
run_id: string; started_at: number; finished_at: number | null
// What the engine recorded about the declaration it judged against.
metadata?: { policy?: {
declared?: boolean; guests_declared?: number
storages_declared?: number; thresholds_declared?: string[]
} } | null
} | null
findings: Array<{
check_id: string; area: string; severity: string
classification: string; decision?: string
summary_key: string | null; summary_params: Record<string, unknown>
affected: Array<Record<string, unknown>>; evidence: string | null
incomplete?: boolean
sources?: Array<{ source: string; collected_at?: number; error?: string }>
exception?: { reason: string; accepted_by: string; accepted_at: number } | null
}>
inventory: any | null
t: Translate
locale: string
}
// One scale, worst first. An observation is drawn in a neutral blue
// rather than an alarm colour: it describes the host, it is not a fault.
const ORDER = ["critical", "warning", "observation", "unverified",
"accepted", "conformant", "not_applicable"]
const CLASS_COLOR: Record<string, string> = {
critical: "#dc2626", warning: "#ca8a04", observation: "#3b82f6",
unverified: "#94a3b8", accepted: "#4f46e5", conformant: "#16a34a",
not_applicable: "#cbd5e1",
}
/** What a finding reads as once the reader's decision is applied. */
function shownAs(f: { classification: string; decision?: string }): string {
return f.decision === "accepted" ? "accepted" : f.classification
}
function bytes(value: number | null | undefined): string {
if (!value || value <= 0) return "—"
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
let n = value, i = 0
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}`
}
/** Instants reach the document as epoch seconds or as an ISO string,
* depending on which store recorded them. */
function when(value: number | string | null | undefined, locale: string): string {
return auditInstant(value, locale)
}
function chip(state: string, label: string): string {
const mark = state === "critical" ? "×" : state === "warning" ? "!" : state === "conformant" ? "✓" : state === "observation" ? "ⓘ" : state === "unverified" ? "?" : ""
return `<span class="chip ${esc(state)}"><span aria-hidden="true">${mark}</span> ${esc(label)}</span>`
}
function summaryOf(f: DocumentInput["findings"][number], t: Translate,
breakdown = false): string {
// The per-result breakdown belongs beside the table it describes. In
// the one-line findings summary it replaced the sentence with a bare
// count, which read as a broken cell next to every other row.
if (breakdown && f.check_id === "backup.last_backup_age" && f.affected.length) {
return resultBreakdown(f, t)
}
// A check that found nothing to apply to used to carry an English
// sentence written by the engine; it now says so in the reader's own.
if (!f.summary_key) {
return f.classification === "not_applicable" ? t("audit.notApplicableScope") : ""
}
const params: Record<string, string> = {}
for (const [k, v] of Object.entries(f.summary_params || {})) params[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
}
/**
* Evidence, rendered as the reader would want to read it rather than as
* the check happened to serialise it.
*/
function evidenceHtml(evidence: string | null, locale: string,
compact?: { t: Translate; rows?: number; lines?: number; blocks?: number }): string {
let blocks = parseEvidence(evidence, locale)
if (blocks.length === 0) return ""
let omitted = false
if (compact?.blocks && blocks.length > compact.blocks) {
blocks = blocks.slice(0, compact.blocks)
omitted = true
}
const parts = blocks.map((block) => {
const heading = block.title
? `<p class="evidence-title">${esc(block.title)}</p>` : ""
if (block.kind === "table") {
const rows = compact?.rows && block.rows.length > compact.rows
? (omitted = true, block.rows.slice(0, compact.rows)) : block.rows
if (block.columns.length > 6) {
return heading + rows.map(row => `<div class="evidence-record">` + table([], block.columns.map((column, i) => [esc(column), esc(row[i])])) + `</div>`).join("")
}
return heading + table(block.columns, rows.map((r) => r.map(esc)))
}
if (block.kind === "pairs") {
const entries = compact?.rows && block.entries.length > compact.rows
? (omitted = true, block.entries.slice(0, compact.rows)) : block.entries
return heading + table([], entries.map(([k, v]) =>
[`<span class="muted">${esc(k)}</span>`, esc(v)]))
}
const lines = compact?.lines && block.lines.length > compact.lines
? (omitted = true, block.lines.slice(0, compact.lines)) : block.lines
return heading + (lines.length
? `<ul class="evidence-list">${lines.map((l) =>
`<li>${esc(l)}</li>`).join("")}</ul>` : "")
})
const notice = omitted && compact
? `<p class="evidence-excerpt-note">${esc(auditLabel(compact.t, "evidenceExcerpt"))}</p>` : ""
return `<div class="evidence-block">${parts.join("")}${notice}</div>`
}
// ---------------------------------------------------------------------------
// Assessment summary
// ---------------------------------------------------------------------------
function executiveSummary(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
const counts: Record<string, number> = {}
for (const f of findings) {
const shown = shownAs(f)
counts[shown] = (counts[shown] || 0) + 1
}
const fails = counts.critical || 0
const warns = counts.warning || 0
// Coverage measures verified checks, not whether their result is favourable.
// Decisions/acceptances never turn missing evidence into verified evidence.
const applicable = findings.filter(f => f.classification !== "not_applicable")
const verifiedChecks = applicable.filter(f => !f.incomplete &&
["critical", "warning", "observation", "conformant", "accepted"].includes(f.classification))
const verified = verifiedChecks.length
const incomplete = verified < applicable.length || !!(input.run && !input.run.finished_at)
const coverage = applicable.length ? verified / applicable.length * 100 : 0
const coverageValue = applicable.length ? `${verified}/${applicable.length}` : "—"
const byArea: Record<string, Record<string, number>> = {}
for (const f of findings) {
const shown = shownAs(f)
byArea[f.area] = byArea[f.area] || {}
byArea[f.area][shown] = (byArea[f.area][shown] || 0) + 1
}
const chart = findingsChart(byArea, (a) => t(`audit.areas.${a}`), CLASS_COLOR, ORDER)
const legend = ORDER.filter((c) => counts[c]).map((s) =>
`<span style="display:inline-flex;align-items:center;gap:5px;margin-right:14px">
<span style="width:10px;height:10px;border-radius:2px;display:inline-block;
background:${CLASS_COLOR[s]}"></span>${esc(t(`audit.classifications.${s}`))}</span>`).join("")
const body = `
<div class="exec-box">
<div class="audit-verification-ring">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle cx="60" cy="60" r="54" fill="none" stroke="#e2e8f0" stroke-width="5" />
<circle cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="5"
pathLength="100" stroke-dasharray="${coverage} 100" transform="rotate(-90 60 60)" />
</svg>
<div class="audit-verification-value"><strong>${coverageValue}</strong>
<span>${esc(auditLabel(t, "verified"))}</span></div>
</div>
<div class="exec-text">
<h3 class="audit-result-heading">${icon("summary", 22, "#64748b")}${esc(t("audit.document.verdictHeading"))}</h3>
${!findings.length ? `<p>${esc(t("audit.document.verdictText.none"))}</p>` : !applicable.length ? `<p>${esc(auditLabel(t, "noApplicable"))}</p>` : ""}
${incomplete ? `<p class="assessment-incomplete">${esc(auditLabel(t, "incomplete"))}</p>` : ""}
<p class="muted">${esc(auditLabel(t, "verificationScope"))}</p>
<p style="font-size:11px;color:#64748b;margin-top:6px">
${esc(t("audit.document.runAt", { date: when(input.run?.started_at, locale) }))}
</p>
</div>
</div>
<div class="audit-counters">${[
card(t("audit.classifications.critical"), String(fails),
{ center: true, color: CLASS_COLOR.critical }),
card(t("audit.classifications.warning"), String(warns),
{ center: true, color: CLASS_COLOR.warning }),
card(t("audit.classifications.observation"), String(counts.observation || 0),
{ center: true, color: CLASS_COLOR.observation }),
card(t("audit.classifications.conformant"), String(counts.conformant || 0),
{ center: true, color: CLASS_COLOR.conformant }),
...["unverified", "accepted", "not_applicable"].filter(c => counts[c]).map(c => card(t(`audit.classifications.${c}`), String(counts[c]), {center: true, color: CLASS_COLOR[c]})),
].join("")}</div>
${chart ? `<div class="diagram" style="margin-top:14px">
<p class="diagram-note">${esc(t("audit.document.chartNote"))}</p>${chart}
<div style="margin-top:10px;font-size:10px;color:#475569">${legend}</div>
</div>` : ""}`
const overview = findings.filter(f => !["conformant", "not_applicable"].includes(shownAs(f))).sort((a,b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b)))
const listing = overview.length ? heading(auditLabel(t, "overview"), "findings") + table(
[auditLabel(t, "result"), t("audit.document.name"), auditLabel(t, "fact")],
overview.map(f => [chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
`<a href="#finding-${esc(f.check_id)}">${esc(t(`audit.checks.${f.check_id}.title`))}</a>`, esc(summaryOf(f,t))])) : ""
// The list and ring share the same records, so the numerator is auditable.
const checkList = (id: string, title: string, checks: DocumentInput["findings"]) => {
if (!checks.length) return ""
const sorted = [...checks].sort((a, b) =>
t(`audit.areas.${a.area}`).localeCompare(t(`audit.areas.${b.area}`), locale) ||
t(`audit.checks.${a.check_id}.title`).localeCompare(t(`audit.checks.${b.check_id}.title`), locale))
return `<div class="audit-checks-inventory" id="${id}">` +
heading(`${title} · ${checks.length}`, "summary") + table(
[auditLabel(t, "checkName"), t("audit.document.area"), auditLabel(t, "result")],
sorted.map(f => [
`<a href="#finding-${esc(f.check_id)}">${esc(t(`audit.checks.${f.check_id}.title`))}</a>`,
esc(t(`audit.areas.${f.area}`)), chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
])) + `</div>`
}
const checked = checkList("verified-checks", auditLabel(t, "verifiedChecks"), verifiedChecks)
const unverified = checkList("unverified-checks", auditLabel(t, "unverifiedChecks"),
applicable.filter(f => !verifiedChecks.includes(f)))
const notApplicable = checkList("not-applicable-checks", t("audit.classifications.not_applicable"),
findings.filter(f => f.classification === "not_applicable"))
return section(n, t("audit.document.executiveSummary"), body + checked + unverified + notApplicable + listing, "summary")
}
// ---------------------------------------------------------------------------
// Identity and cluster
// ---------------------------------------------------------------------------
function identitySection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const id = s.identity
const { t } = input
if (!id) return ""
const hw = s.hardware || {}
const body = grid(3, [
card(t("audit.inventory.node"), esc(id.node)),
card(t("audit.inventory.pveVersion"), esc(String(id.pve_version || "—").match(/pve-manager\/([^/]+)/)?.[1] || id.pve_version)),
card(t("audit.inventory.kernel"), esc(id.kernel)),
card(t("audit.inventory.subscription"), esc(subscriptionLabel(t, id.subscription))),
card(t("audit.inventory.cluster"), esc(id.cluster || t("audit.inventory.standalone"))),
card(t("audit.document.system"),
esc([hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ") || "—")),
])
return section(n, t("audit.document.nodeIdentity"), body, "node")
}
function clusterSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
if (!("cluster" in s)) return ""
const cluster = s.cluster
if (!cluster) {
return section(n, t("audit.document.cluster"),
callout("info", t("audit.inventory.standalone"),
esc(t("audit.document.standaloneNote"))), "cluster")
}
const diagram = clusterDiagram(cluster, {
thisNode: t("audit.document.thisNode"),
unreachable: t("audit.document.unreachable"),
links: t("audit.document.corosyncLinks"),
})
const rows = (cluster.nodes || []).map((node: any) => [
esc(node.name) + (node.local
? ` <span class="muted">(${esc(t("audit.document.thisNode"))})</span>` : ""),
esc(node.nodeid || "—"),
esc(node.ring0_addr || "—"),
esc(node.ring1_addr || "—"),
node.online === false
? chip("warn", t("audit.document.unreachable"))
: node.online === true ? chip("pass", t("audit.document.member")) : "—",
])
const body = `
${grid(3, [
card(t("audit.inventory.cluster"), esc(cluster.name)),
card(t("audit.document.quorum"), cluster.quorate == null
? "—" : chip(cluster.quorate ? "pass" : "fail",
t(cluster.quorate ? "audit.document.quorate" : "audit.document.inquorate"))),
card(t("audit.document.votes"),
esc(`${cluster.total_votes ?? "—"} / ${cluster.expected_votes ?? "—"}`)),
])}
${diagram ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.clusterDiagramNote"))}</p>${diagram}
</div>` : ""}
${table([t("audit.document.nodeName"), "nodeid", "ring0", "ring1",
t("audit.document.state")], rows)}`
return section(n, t("audit.document.cluster"), body, "cluster")
}
// ---------------------------------------------------------------------------
// How the node is built
// ---------------------------------------------------------------------------
function architectureSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const hw = s.hardware
const { t } = input
if (!hw) return ""
const cpu = hw.cpu || {}
const mem = hw.memory || {}
const diagram = nodeArchitectureDiagram(hw, s.identity || {}, {
chassis: t("audit.document.board"),
processor: t("audit.document.processor"),
memory: t("audit.document.memory"),
controllers: t("audit.document.controllers"),
disks: t("audit.document.disks"),
adapters: t("audit.document.adapters"),
slotsUsed: t("audit.document.slotsUsed"),
cores: t("audit.document.cores"),
threads: t("audit.document.threads"),
empty: t("audit.document.emptySlot"),
})
const identityRows = [
[t("audit.document.manufacturer"), esc(hw.system?.manufacturer || "—")],
[t("audit.document.product"), esc(hw.system?.product || "—")],
[t("audit.document.serial"), esc(hw.system?.serial || "—")],
[t("audit.document.board"),
esc([hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ") || "—")],
["BIOS", esc([hw.bios?.vendor, hw.bios?.version, hw.bios?.date]
.filter(Boolean).join(" · ") || "—")],
]
const memoryRows = (mem.modules || []).map((m: any) => [
esc(m.locator || "—"), esc(m.size || "—"), esc(m.type || "—"),
esc(m.form_factor || "—"), esc(m.speed || "—"),
esc([m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—"),
])
const controllerRows = (hw.controllers || []).map((c: any) => [
`<span class="muted" style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(c.slot)}</span>`,
esc(c.class), esc(c.name),
])
const body = `
${grid(4, [
card(t("audit.document.processor"), esc(cpu.model || "—")),
card(t("audit.document.topology"),
esc(`${cpu.sockets || 1} × ${cpu.cores_per_socket || "?"} / ${cpu.threads || "?"}`)),
card(t("audit.document.memory"), esc(bytes(hw.memory_bytes))),
card(t("audit.document.iommuGroups"), esc(String(hw.iommu_groups ?? "—"))),
])}
${diagram ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.architectureNote"))}</p>${diagram}
</div>` : ""}
${heading(t("audit.document.systemIdentity"), "node")}
${table([t("audit.document.field"), t("audit.document.value")], identityRows)}
${memoryRows.length ? `
${heading(t("audit.document.memoryModules"), "memory",
t("audit.document.slotsFilled", { used: String(mem.populated ?? 0),
total: String(mem.slots ?? mem.populated ?? 0) }))}
${table([t("audit.document.slot"), t("audit.document.size"), t("audit.document.type"),
t("audit.document.formFactor"), t("audit.document.speed"),
t("audit.document.manufacturer")], memoryRows)}` : ""}
${controllerRows.length ? `
${heading(t("audit.document.controllers"), "controller")}
${table(["PCI", t("audit.document.class"), t("audit.document.device")], controllerRows)}` : ""}`
return section(n, t("audit.document.architecture"), body, "architecture")
}
// ---------------------------------------------------------------------------
// Disks, with what has been observed of them
// ---------------------------------------------------------------------------
function disksSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const disks = s.hardware?.disks || []
const { t, locale } = input
if (!disks.length) return ""
const rows = disks.map((d: any) => {
const life = typeof d.power_on_hours === "number" && d.power_on_hours > 0
? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) })
: "—"
// smartctl reports the overall assessment as "PASSED"; the Monitor
// normalises some devices to "healthy".
const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase())
const health = ok ? chip("pass", t("audit.document.healthy"))
: d.health && d.health !== "unknown" ? chip("warn", esc(d.health)) : "—"
return [
`<strong>${esc(d.name)}</strong>`,
esc(d.model || "—"),
`<span class="muted" style="font-size:10.5px">${esc(d.serial || "—")}</span>`,
esc(bytes(d.size_bytes)),
esc(d.bus ? d.bus.toUpperCase() : "—") + (d.rotational ? " · HDD" : " · SSD"),
health,
esc(life),
d.observations?.length
? chip("warn", String(d.observations.length))
: `<span class="muted">—</span>`,
]
})
// Observations are the disk's history. SMART reports what is true now;
// the log reports what happened. A disk that recovered still recorded
// the event, and that pattern is what precedes a failure.
const withEvents = disks.filter((d: any) => (d.observations || []).length)
const observations = withEvents.map((d: any) => {
const entries = d.observations.map((o: any) => [
esc(o.type || "—"),
// The stored severity is an English database value, and this page
// exists in eight languages.
o.severity === "critical"
? chip("fail", esc(t("audit.classifications.critical")))
: o.severity ? chip("warn", esc(t("audit.classifications.warning"))) : "—",
esc(String(o.count ?? "—")),
esc(when(o.first_seen, locale)),
esc(when(o.last_seen, locale)),
`<span class="muted" style="font-size:10.5px">${esc(o.message || "")}</span>`,
])
return `${heading(d.name, "disks", d.model || undefined)}
${table([t("audit.document.event"), t("audit.document.severity"),
t("audit.document.occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), t("audit.document.detail")], entries)}`
}).join("")
const body = `
${table([t("audit.document.device"), t("audit.document.model"),
t("audit.document.serial"), t("audit.document.size"),
t("audit.document.bus"), "SMART", t("audit.document.serviceLife"),
t("audit.document.events")], rows)}
${heading(t("audit.document.observations"), "observation")}
${withEvents.length
? `<p class="diagram-note">${esc(t("audit.document.observationsNote"))}</p>${observations}`
: callout("ok", t("audit.document.noObservations"),
esc(t("audit.document.noObservationsNote")))}`
return section(n, t("audit.document.storageDevices"), body, "disks")
}
// ---------------------------------------------------------------------------
// Network
// ---------------------------------------------------------------------------
function chain(hops: Array<{ id: string; mode?: string }> | null, t: Translate): string {
if (hops === null) return `<span class="muted">${esc(t("audit.inventory.unresolved"))}</span>`
if (hops.length === 0) return `<span class="muted">${esc(t("audit.inventory.noUplink"))}</span>`
return hops.map((h) => esc(h.id + (h.mode ? ` · ${h.mode}` : "")))
.join('<span class="sep">&rarr;</span>')
}
function networkSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
const net = s.network
const guests = s.guests || []
const adapters = s.hardware?.adapters || []
if (!net && !adapters.length) return ""
const diagram = net?.bridges
? networkDiagram(net.bridges, guests, {
nic: t("audit.document.adapters"), bond: t("audit.document.bond"),
bridge: t("audit.document.bridge"), guests: t("audit.inventory.guests"),
})
: ""
const adapterRows = adapters.map((a: any) => [
`<strong>${esc(a.name)}</strong>`,
a.state === "up" ? chip("pass", esc(a.state)) : chip("unknown", esc(a.state || "—")),
a.speed_mbps
? esc(a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`)
: "—",
`<span class="muted" style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(a.mac || "—")}</span>`,
esc(a.driver || "—"),
`<span class="muted" style="font-size:10.5px">${esc(a.pci || "—")}</span>`,
])
const bridgeRows = Object.entries(net?.bridges || {}).map(([id, b]: [string, any]) => [
`<strong>${esc(id)}</strong>`,
chain(b.uplink ?? null, t),
esc(String(guests.filter((g: any) =>
(g.interfaces || []).some((i: any) => i.bridge === id)).length)),
])
const body = `
${diagram ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.networkDiagramNote"))}</p>${diagram}
</div>` : ""}
${adapterRows.length ? `
${heading(t("audit.document.physicalAdapters"), "adapter")}
${table([t("audit.document.interface"), t("audit.document.state"),
t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"],
adapterRows)}` : ""}
${bridgeRows.length ? `
${heading(t("audit.document.bridges"), "bridge")}
${table([t("audit.document.bridge"), t("audit.document.uplink"),
t("audit.inventory.guests")], bridgeRows)}` : ""}`
return section(n, t("audit.document.network"), body, "network")
}
function latencySection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
const latency = s.latency
if (!latency?.targets?.length) return ""
// The legend reads in the reader's language, like the table under it.
const named = latency.targets.map((target: any) => ({
...target, label: t(`audit.document.target.${target.target}`),
}))
const chart = latencyChart(named, {
ms: t("audit.document.milliseconds"), hours: t("audit.document.hours"),
})
const ms = (v: number | null | undefined) =>
typeof v === "number" ? `${v} ms` : "—"
const rows = latency.targets.map((target: any) => [
`<strong>${esc(t(`audit.document.target.${target.target}`))}</strong>`,
esc(ms(target.min_ms)), esc(ms(target.avg_ms)), esc(ms(target.max_ms)),
esc(typeof target.packet_loss === "number" ? `${target.packet_loss} %` : "—"),
esc(String(target.samples)),
])
const body = `
${chart ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.latencyNote"))}</p>${chart}
</div>` : ""}
${table([t("audit.document.target.label"), t("audit.document.minimum"),
t("audit.document.average"), t("audit.document.maximum"),
t("audit.document.packetLoss"), t("audit.document.samples")], rows)}`
return section(n, t("audit.document.latency"), body, "latency")
}
// ---------------------------------------------------------------------------
// Storage and protection
// ---------------------------------------------------------------------------
function storageSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
const storages = s.storages || []
const guests = s.guests || []
if (!storages.length) return ""
const diagram = storageDiagram(guests, {
guests: t("audit.inventory.guests"), storage: t("audit.document.storage"),
backup: t("audit.document.backupDestination"),
unprotected: auditLabel(t,"noJob"),
})
const rows = storages.map((st: any) => [
`<strong>${esc(st.id)}</strong>`,
esc(st.type),
esc(st.content || "—"),
st.shared ? chip("pass", t("audit.document.shared")) : `<span class="muted">—</span>`,
esc(st.server || st.path || "—"),
esc(String(guests.filter((g: any) =>
(g.disks || []).some((d: any) => d.storage === st.id)).length)),
])
const unprotected = guests.filter((g: any) => !(g.backups || []).length)
const selected = guests.length - unprotected.length
const fraction = guests.length ? selected / guests.length * 100 : 0
const capacityFinding = input.findings.find(f => f.check_id === "storage.connected_storage")
let capacityRows: any[] = []
try { capacityRows = JSON.parse(capacityFinding?.evidence || "{}").storages || [] } catch { /* Raw evidence stays in the appendix. */ }
const capacity = capacityRows.filter(r => Number(r.total) > 0 && r.used != null).map(r => {
const ratio = Math.max(0, Math.min(100, Number(r.used) / Number(r.total) * 100))
return `<div class="capacity-item"><strong>${esc(r.storage)}</strong><span>${esc(bytes(Number(r.used)))} / ${esc(bytes(Number(r.total)))}</span><div class="audit-meter"><span style="width:${ratio}%"></span></div></div>`
}).join("")
const body = `
${guests.length ? `<div class="coverage-panel"><h3>${icon("storage")}${esc(auditLabel(t,"coverage"))}</h3>
<div class="audit-meter"><span style="width:${fraction}%"></span></div>
<div class="coverage-labels"><span>${selected} / ${guests.length} · ${esc(auditLabel(t,"scheduled"))}</span><span>${unprotected.length} · ${esc(auditLabel(t,"noJob"))}</span></div>
<p class="muted">${esc(auditLabel(t,"copyScope"))}</p></div>` : ""}
${diagram ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.storageDiagramNote"))}</p>${diagram}
</div>` : ""}
${table([t("audit.document.storage"), t("audit.document.type"),
t("audit.document.content"), t("audit.document.shared"),
t("audit.document.location"), t("audit.inventory.guests")], rows)}
${capacity ? heading(auditLabel(t,"capacity"), "storage") + capacity : ""}
${unprotected.length
? callout("info", t("audit.document.unprotectedGuests",
{ count: String(unprotected.length) }),
esc(unprotected.map((g: any) => `${g.vmid} ${g.name}`).join(" · ")))
: callout("info", auditLabel(t,"scheduled"),
esc(auditLabel(t,"copyScope")))}`
return section(n, t("audit.document.storageAndProtection"), body, "storage")
}
// ---------------------------------------------------------------------------
// Guests, passthrough, managed software
// ---------------------------------------------------------------------------
function guestsSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const guests = s.guests || []
const { t } = input
if (!guests.length) return ""
const rows = guests.map((g: any) => [
`<strong>${esc(String(g.vmid))}</strong>`,
esc(g.name || "—"),
g.type === "lxc" ? "LXC" : "VM",
esc(String(g.cores || "—")),
esc(g.memory ? bytes(Number(g.memory) * 1024 * 1024) : "—"),
esc([...new Set((g.disks || []).map((d: any) => d.storage).filter(Boolean))].join(", ") || "—"),
esc([...new Set((g.interfaces || []).map((i: any) => i.bridge).filter(Boolean))].join(", ") || "—"),
(g.backups || []).length
? esc((g.backups || []).map((b: any) => b.storage).join(", "))
: esc(auditLabel(t,"noJob")),
])
return section(n, t("audit.inventory.guests"),
table([t("audit.document.vmid"), t("audit.document.name"), t("audit.document.kind"),
t("audit.document.cores"), t("audit.document.memory"),
t("audit.document.storage"), t("audit.document.bridge"),
t("audit.document.backup")], rows), "guests")
}
function passthroughSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const devices = s.passthrough || []
const { t } = input
if (!devices.length) return ""
const rows = devices.map((d: any) => [
esc(String(d.vmid)),
esc(d.guest || "—"),
esc(d.slot || "—"),
`<span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(d.address || "—")}</span>`,
esc((d.iommu_groups || []).join(", ") || "—"),
(d.shared_group_devices || []).length
? chip("warn", String(d.shared_group_devices.length))
: `<span class="muted">—</span>`,
])
return section(n, t("audit.inventory.passthrough"),
table([t("audit.document.vmid"), t("audit.document.name"), t("audit.document.slot"),
t("audit.document.device"), t("audit.document.iommuGroup"),
auditLabel(t,"otherDevices")], rows), "passthrough")
}
function proxmenuxSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
const pmx = s.proxmenux
const apps = s.applications || []
if (!pmx && !apps.length) return ""
const toolRows = (pmx?.optimizations || []).map((tool: any) => {
const pending = (pmx?.pending_updates || []).find((u: any) => u.key === tool.key)
return [
esc(tool.key.replace(/_/g, " ")), esc(tool.version === "True" || tool.version === "False" ? auditLabel(t,"unversioned") : tool.version || auditLabel(t,"unversioned")),
pending ? chip("warn", t("audit.document.updateAvailable",
{ version: String(pending.available) }))
: esc(auditLabel(t,"noPendingRecorded")),
]
})
const appRows = apps.map((a: any) => [
esc(a.name || "—"), esc(String(a.vmid ?? "—")),
esc(a.version || t("audit.inventory.versionUnknown")),
])
const body = `
${toolRows.length ? `
${heading(t("audit.inventory.proxmenux"), "software")}
${table([t("audit.document.name"), t("audit.document.version"),
t("audit.document.state")], toolRows)}` : ""}
${appRows.length ? `
${heading(t("audit.inventory.applications"), "software")}
${table([t("audit.document.name"), t("audit.document.vmid"),
t("audit.document.version")], appRows)}` : ""}`
return section(n, t("audit.document.managedSoftware"), body, "software")
}
// ---------------------------------------------------------------------------
// Findings in full
// ---------------------------------------------------------------------------
function findingsSection(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
if (findings.length === 0) return ""
const areas = Array.from(new Set(findings.map((f) => f.area))).sort()
const parts: string[] = []
for (const area of areas) {
// Within an area the reader still wants the worst first.
const rows = findings.filter((f) => f.area === area)
.sort((a, b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b)))
parts.push(`${heading(t(`audit.areas.${area}`))}`)
for (const f of rows) {
const groups = presentFinding(f, t, locale, input.inventory?.sections?.guests || [])
const bits = [
`<div class="finding-head" id="finding-${esc(f.check_id)}">`,
chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
`<span class="title">${esc(t(`audit.checks.${f.check_id}.title`))}</span>`,
f.incomplete ? chip("unknown", t("audit.document.incomplete")) : "",
`</div>`,
]
const summary = summaryOf(f, t, true)
if (summary) bits.push(`<p>${esc(summary)}</p>`)
// "Could not be evaluated" describes the assessment, not the host.
const unread = unreadSources(f.sources, t)
if (unread) bits.push(`<p class="muted">${esc(unread)}</p>`)
bits.push(`<p class="rationale">${esc(t(`audit.checks.${f.check_id}.rationale`))}</p>`)
if (f.exception) {
bits.push(`<p><strong>${esc(t("audit.detail.acceptedRisk"))}:</strong> ` +
`${esc(f.exception.reason)}${esc(f.exception.accepted_by)}, ` +
`${esc(when(f.exception.accepted_at, locale))}</p>`)
}
for (const group of groups) {
bits.push(heading(group.title), group.note ? `<p class="muted">${esc(group.note)}</p>` : "", table(group.columns, group.rows.map(row => row.cells.map(esc))))
}
if (f.evidence && shownAs(f) === "conformant" && groups.length === 0) {
bits.push(heading(auditLabel(t, "evidenceObserved"), "scope"),
evidenceHtml(f.evidence, locale, { t, rows: 4, lines: 5, blocks: 3 }))
} else if (f.evidence && f.classification !== "not_applicable") {
bits.push(`<p class="technical-ref"><a href="#evidence-${esc(f.check_id)}">${esc(auditLabel(t,"detailsLink"))}: ${esc(f.check_id)}</a></p>`)
}
const rowCount = groups.reduce((total, group) => total + group.rows.length, 0)
parts.push(`<div class="finding ${esc(shownAs(f))} ${rowCount <= 4 ? "finding-short" : "finding-long"}">${bits.join("\n")}</div>`)
}
}
return section(n, t("audit.document.findings"), parts.join("\n"), "findings")
}
// ---------------------------------------------------------------------------
// Quick diagnosis
// ---------------------------------------------------------------------------
/** How many affected rows a diagnostic prints before it stops counting. */
const DIAGNOSTIC_ROW_CAP = 8
/**
* What the host is asking its administrator to decide, and nothing else.
*
* The full report answers "what is this machine"; this one answers "what
* do I do now". Everything conformant is left out on purpose: a document
* that prints thirty passing checks to reach five failing ones makes the
* five harder to find, which is the opposite of a diagnosis.
*/
function diagnosticSummary(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
const acting = findings.filter((f) => ["critical", "warning"].includes(shownAs(f)))
const counts = ["critical", "warning"].map((c) => ({
key: c, total: findings.filter((f) => shownAs(f) === c).length,
}))
const node = input.inventory?.sections?.identity?.node || t("audit.document.unknownNode")
const ran = input.run?.finished_at ?? input.run?.started_at
const body = grid(4, [
card(t("audit.document.node"), esc(String(node))),
card(t("audit.document.generated"), esc(when(ran, locale))),
...counts.map((c) => card(t(`audit.classifications.${c.key}`), String(c.total))),
])
const verdict = acting.length
? `<p>${esc(t("audit.document.diagnosticActing", { count: String(acting.length) }))}</p>`
: `<p>${esc(t("audit.document.diagnosticClear"))}</p>`
return section(n, t("audit.document.diagnosticTitle"), body + verdict, "summary")
}
/**
* Each finding that asks for a decision, with the evidence needed to
* take it and no more. Long tables are cut: thirty identical rows say
* the same thing the first eight already said, and the reader who wants
* every one of them wants the full report.
*/
function actionsSection(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
const acting = findings
.filter((f) => ["critical", "warning"].includes(shownAs(f)))
.sort((a, b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b)))
if (!acting.length) return ""
const parts = acting.map((f) => {
const bits = [
`<div class="finding-head" id="finding-${esc(f.check_id)}">`,
chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
`<span class="title">${esc(t(`audit.checks.${f.check_id}.title`))}</span>`,
`<span class="muted">${esc(t(`audit.areas.${f.area}`))}</span>`,
`</div>`,
]
const summary = summaryOf(f, t)
if (summary) bits.push(`<p>${esc(summary)}</p>`)
const unread = unreadSources(f.sources, t)
if (unread) bits.push(`<p class="muted">${esc(unread)}</p>`)
bits.push(`<p class="rationale">${esc(t(`audit.checks.${f.check_id}.rationale`))}</p>`)
for (const group of presentFinding(f, t, locale, input.inventory?.sections?.guests || [])) {
const shown = group.rows.slice(0, DIAGNOSTIC_ROW_CAP)
bits.push(heading(group.title),
table(group.columns, shown.map((row) => row.cells.map(esc))))
if (group.rows.length > shown.length) {
bits.push(`<p class="muted">${esc(t("audit.document.diagnosticMoreRows",
{ count: String(group.rows.length - shown.length) }))}</p>`)
}
}
return `<div class="finding ${esc(shownAs(f))}">${bits.join("\n")}</div>`
})
// The same heading the full report uses: naming the section after what
// the reader is expected to do with it was a judgement the document
// has no business making.
return section(n, t("audit.document.findings"), parts.join("\n"), "findings")
}
/**
* Readings that could not be taken. Kept because a diagnosis that hides
* its own blind spots is worse than one that names them.
*/
function unreadSection(input: DocumentInput, n: number): string {
const { findings, t } = input
const unread = findings.filter((f) => f.classification === "unverified")
if (!unread.length) return ""
const rows = unread.map((f) => [
esc(t(`audit.checks.${f.check_id}.title`)),
esc(t(`audit.areas.${f.area}`)),
esc(summaryOf(f, t)),
])
return section(n, t("audit.document.diagnosticUnread"),
table([t("audit.presentation.checkName"), t("audit.document.area"),
auditLabel(t, "fact")], rows), "scope")
}
// ---------------------------------------------------------------------------
// Scope
// ---------------------------------------------------------------------------
function evidenceSection(input: DocumentInput, n: number): string {
// Passing checks carry a compact evidence excerpt beside their result.
// The appendix is reserved for findings whose evidence an operator may
// need to investigate, which keeps a useful report from becoming dozens
// of pages of successful raw probes.
const rows = input.findings.filter(f => f.evidence &&
!["conformant", "not_applicable"].includes(shownAs(f)))
if (!rows.length) return ""
return section(n, auditLabel(input.t,"annex"), `<p class="muted">${esc(auditLabel(input.t,"annexScope"))}</p>` + rows.map(f =>
`<div class="technical-entry" id="evidence-${esc(f.check_id)}">` + heading(input.t(`audit.checks.${f.check_id}.title`), "scope", f.check_id) +
evidenceHtml(f.evidence, input.locale) + `</div>`).join(""), "scope")
}
function scopeSection(input: DocumentInput, n: number): string {
const { t, inventory } = input
const missing = Object.entries(inventory?.unavailable || {})
// The engine records which declaration it judged against. A report
// that omits it reads identically whether the host was measured
// against stated expectations or against none, and those are two
// different reports about the same machine.
const policy = input.run?.metadata?.policy
const declared = policy?.declared
? t("audit.document.policyDeclared", {
guests: String(policy.guests_declared ?? 0),
storages: String(policy.storages_declared ?? 0),
thresholds: String((policy.thresholds_declared || []).length),
})
: t("audit.document.policyNone")
const body = `
<div class="scope">
<p style="margin:0">${esc(t("audit.document.scopeText",
{ profile: t(`audit.profile.${input.profile}`) }))}</p>
<ul>
<li>${esc(t("audit.document.scopeLocal"))}</li>
<li>${esc(auditLabel(t,"readOnlyScope"))}</li>
<li>${esc(t("audit.document.scopeMoment"))}</li>
<li>${esc(declared)}</li>
</ul>
${missing.length ? `
<p style="margin:12px 0 4px"><strong>${esc(t("audit.document.notRead"))}</strong></p>
<ul>${missing.map(([k, v]) =>
`<li>${esc(k)}: ${esc(String(v))}</li>`).join("")}</ul>` : ""}
</div>`
return section(n, t("audit.document.scope"), body, "scope")
}
// ---------------------------------------------------------------------------
export function buildAuditDocument(input: DocumentInput): string {
const { t, locale } = input
const node = input.inventory?.sections?.identity?.node || t("audit.document.unknownNode")
const id = reportId("AUDIT")
// The quick diagnosis is a different document, not the same one with
// sections withheld: it opens on what needs a decision instead of on
// what the machine is, and it prints no inventory, no diagrams and no
// annex. Everything is still assessed — only the printing is short.
// Structure and configuration, with nothing assessed. The profile runs
// no checks, so an assessment summary above it counted nothing and a
// findings section below it listed nothing: two empty frames around
// the only thing the reader opened this for.
const builders = input.profile === "inventory"
? [
identitySection, clusterSection, architectureSection, disksSection,
networkSection, latencySection, storageSection, guestsSection,
passthroughSection, proxmenuxSection, scopeSection,
]
: input.profile === "diagnostic"
? [diagnosticSummary, actionsSection, unreadSection, scopeSection]
: [
executiveSummary, identitySection, clusterSection, architectureSection,
disksSection, networkSection, latencySection, storageSection, guestsSection,
passthroughSection, proxmenuxSection, findingsSection, scopeSection, evidenceSection,
]
// A section a profile did not ask for produces nothing, and the
// numbering closes over the gap rather than skipping a number. Each
// builder is therefore called once the previous one is known to have
// produced something, not in a pass of its own.
const sections: string[] = []
for (const build of builders) {
const html = build(input, sections.length + 1)
if (html) sections.push(html)
}
const body = sections.join("\n").replace(/<table\b/g, '<div class="audit-table-scroll"><table').replace(/<\/table>/g, '</table></div>')
// A document that assesses nothing should not be titled as an audit.
const documentKey = input.profile === "diagnostic" ? "diagnostic"
: input.profile === "inventory" ? "structure" : ""
return renderReport({
title: documentKey ? t(`audit.document.${documentKey}Title`) : t("audit.document.title"),
subtitle: documentKey ? t(`audit.document.${documentKey}Subtitle`, { node })
: t("audit.document.subtitle", { node }),
topBarSubtitle: node,
meta: [
[t("audit.document.node"), node],
[t("audit.document.profile"), t(`audit.profile.${input.profile}`)],
[t("audit.document.generated"), new Date().toLocaleString(locale)],
],
reportId: id,
logoUrl: `${window.location.origin}/images/proxmenux-logo.png`,
footerLeft: `ProxMenux · ${t("audit.document.title")} · ${node}`,
footerRight: `${id} · ${new Date().toLocaleDateString(locale)}`,
lang: locale,
extraCss: REPORT_CSS_AUDIT + `@page { @bottom-left { content: "ProxMenux · ${esc(String(node)).replace(/["\\\n\r]/g, " ")}"; font-size: 8pt; color: #64748b; } @bottom-right { content: counter(page) " / " counter(pages); font-size: 8pt; color: #64748b; } }`,
body,
})
}
/**
* The window is opened by the caller on the click itself so the popup
* blocker sees the gesture; the document is written into it once the
* inventory has been fetched.
*/
export function openAuditDocument(input: DocumentInput, target: Window | null): void {
writeReport(target, buildAuditDocument(input))
}
export { openReportWindow }
+325
View File
@@ -0,0 +1,325 @@
/** Descriptive view model shared by the Monitor and the printable report.
* Raw evidence remains separate. This layer never proposes an action.
*/
import { splitLeadingJson, durationOf, formatValue } from "./evidence-format"
export type AuditTranslate = (key: string, params?: Record<string, string>) => string
export interface PresentedFinding {
check_id: string
classification: string
affected: Array<Record<string, unknown>>
evidence: string | null
}
export interface AuditGroup {
title: string
note?: string
columns: string[]
rows: Array<{ cells: string[]; classification: string }>
}
export const auditLabel = (t: AuditTranslate, key: string) => t(`audit.presentation.${key}`)
/** El estado que devuelve `pvesubscription get`, en palabras del lector. */
export function subscriptionLabel(t: AuditTranslate, status?: string | null): string {
const key = (status || "").trim().toLowerCase()
if (!key) return ""
const known = ["notfound", "active", "invalid", "expired", "suspended", "new", "unknown"]
return known.includes(key) ? t(`audit.inventory.subscriptionStatus.${key}`) : (status as string)
}
/**
* What a check could not read, in the reader's own words.
*
* A check that reports "could not be evaluated" and stops there
* describes the assessment rather than the host: the reason is recorded
* against each source, but it sat two collapsed sections below a line
* that explained nothing. This is what the finding says out loud
* instead.
*/
export function unreadSources(
sources: Array<{ source: string; error?: string }> | undefined,
t: AuditTranslate,
): string {
const failed = (sources || []).filter((s) => s.error)
if (!failed.length) return ""
const named = failed.map((s) => {
// Sources are recorded as they were invoked — `cmd:["pvesm", …]`.
// The reader wants the command, not its serialisation.
let name = s.source
if (name.startsWith("cmd:")) {
try {
name = (JSON.parse(name.slice(4)) as string[]).join(" ")
} catch {
name = name.slice(4)
}
}
return `${name}${String(s.error).replace(/\s+/g, " ").trim()}`
})
return `${auditLabel(t, "couldNotRead")}: ${named.join(" · ")}`
}
export function auditDuration(hours: number, locale: string): string {
return durationOf(hours, locale)
}
export function evidenceRecords(evidence: string | null): Array<Record<string, any>> {
const parsed = splitLeadingJson((evidence || "").trim())
return parsed && Array.isArray(parsed[0]) ? parsed[0].filter(x => x && typeof x === "object") : []
}
const clean = (v: unknown): string => v === undefined || v === null || v === "-" ? "" : String(v)
/**
* Instants in the audit have two deliberate forms: epoch seconds from
* Proxmox, and local ISO timestamps from the Monitor's SQLite stores.
* A timezone-less SQLite value is already local wall time; treating it
* as UTC shifts it a second time in the printable report.
*/
function auditDate(value: unknown): Date | null {
if (value === undefined || value === null || value === "") return null
if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value))) {
const date = new Date(Number(value) * 1000)
return Number.isNaN(date.getTime()) ? null : date
}
const text = String(value).trim()
const local = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/.exec(text)
const date = local
? new Date(Number(local[1]), Number(local[2]) - 1, Number(local[3]),
Number(local[4]), Number(local[5]), Number(local[6]),
Number((local[7] || "0").slice(0, 3).padEnd(3, "0")))
: new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
export function auditInstant(value: unknown, locale: string): string {
const date = auditDate(value)
return date ? date.toLocaleString(locale) : clean(value) || "—"
}
export function presentFinding(f: PresentedFinding, t: AuditTranslate, locale: string,
guests: Array<{ vmid: number; name?: string; type?: string }> = []): AuditGroup[] {
const label = (key: string) => auditLabel(t, key)
const records = evidenceRecords(f.evidence)
const parsedEvidence = splitLeadingJson((f.evidence || "").trim())
const evidenceObject = parsedEvidence && parsedEvidence[0] &&
typeof parsedEvidence[0] === "object" && !Array.isArray(parsedEvidence[0])
? parsedEvidence[0] as Record<string, any> : null
const guest = (o: Record<string, unknown>) => {
const id = o.vmid ?? o.guest
const known = guests.find(g => String(g.vmid) === String(id))
const type = o.type || known?.type
const prefix = type === "qemu" || type === "vm" ? "VM" : type === "lxc" || type === "ct" ? "LXC" : label("guest")
const name = clean(o.name || known?.name)
return `${name ? name + " · " : ""}${prefix} ${id}`
}
const resource = (o: Record<string, unknown>) => o.vmid !== undefined || o.guest !== undefined
? guest(o) : clean(o.name || o.device || o.storage || o.pool || o.bond || o.interface || o.job || o.package || o.test) || label("host")
// A decision the reader took stands in front of the technical result:
// an object excluded by policy is not a finding at a low gravity, it
// is one that was taken out of the question.
const status = (o: Record<string, unknown>) =>
clean(o.decision) || clean(o.classification) || f.classification
const state = (o: Record<string, unknown>) => t(`audit.classifications.${status(o)}`)
const reason = (o: Record<string, unknown>) => {
const key = `audit.presentation.reasons.${clean(o.reason_key)}`
const translated = t(key)
return translated !== key ? translated : t(`audit.checks.${f.check_id}.title`)
}
const group = (title: string, columns: string[], objects: Array<Record<string, unknown>>,
cells: (o: Record<string, unknown>) => string[]): AuditGroup => ({
title, columns, rows: objects.map(o => ({ cells: cells(o), classification: status(o) })),
})
if (f.check_id === "storage.connected_storage") {
const storages = Array.isArray(evidenceObject?.storages)
? evidenceObject!.storages.filter((o: unknown) => o && typeof o === "object") : []
if (storages.length) {
const objects = storages.map((row: Record<string, unknown>) => {
const finding = f.affected.find(o => o.storage === row.storage)
return { ...row, classification: finding?.classification ||
(["active", "available", "namespace_restricted"].includes(clean(row.status))
? "conformant" : "unverified") }
})
return [group("PVE", [t("audit.document.storage"), t("audit.document.type"),
t("audit.document.state"), label("capacity"), label("fact")], objects, o => {
const dependencyCount = Array.isArray(o.dependencies) ? o.dependencies.length : 0
const jobCount = Array.isArray(o.jobs) ? o.jobs.length : 0
const observed = [dependencyCount ? `${dependencyCount} ${t("audit.inventory.guests")}` : "",
jobCount ? `${jobCount} ${t("audit.document.backup")}` : ""].filter(Boolean).join(" · ") || "—"
const capacity = o.capacity_known && o.used_percent !== undefined
? `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(Number(o.used_percent))} %`
: "—"
return [clean(o.storage), clean(o.type), clean(o.status) || t("audit.classifications.unverified"),
capacity, observed]
})]
}
}
if (f.check_id === "storage.thin_pool_overprovisioning" && records.length) {
const objects = records.map(row => {
const related = f.affected.filter(o => o.pool === row.pool)
const classification = related.some(o => o.classification === "warning") ? "warning"
: related.some(o => o.classification === "observation") ? "observation" : "conformant"
return { ...row, classification,
fact: related.map(reason).filter((v, i, all) => all.indexOf(v) === i).join(" · ") }
})
const pct = (value: unknown) => Number.isFinite(Number(value))
? `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(Number(value))} %` : "—"
return [group(label("records"), [label("resource"), label("capacity"), label("data"), label("metadata"), label("fact")],
objects, o => {
const allocation = Number.isFinite(Number(o.allocation_percent))
? `${pct(o.allocation_percent)} (${formatValue("allocated_bytes", Number(o.allocated_bytes), locale)} / ${formatValue("pool_bytes", Number(o.pool_bytes), locale)})` : "—"
return [clean(o.pool), allocation, pct(o.data_percent), pct(o.metadata_percent),
clean(o.fact) || state(o)]
})]
}
if (f.check_id === "backup.guest_coverage") {
const excluded = f.affected.filter(o => o.reason_key === "dataExcludedFromBackup")
const notSelected = f.affected.filter(o => o.reason_key !== "dataExcludedFromBackup")
const groups = []
if (notSelected.length) groups.push(group(label("unscheduled"), [label("guest"), label("result")],
notSelected, o => [guest(o), state(o)]))
if (excluded.length) {
const ids = [...new Set(excluded.map(o => o.vmid))]
groups.push(group(`${label("excludedDisks")} · ${excluded.length} / ${ids.length} ${label("guests")}`,
[label("guest"), label("disks"), label("result")], ids.map(vmid => ({...excluded.find(o => o.vmid === vmid)!, vmid})),
o => [guest(o), excluded.filter(d => d.vmid === o.vmid).map(d => clean(d.volume)).join(", "), state(o)]))
}
return groups
}
if (f.check_id === "backup.last_backup_age") {
return ["critical", "warning", "observation", "unverified"].flatMap(classification => {
const objects = f.affected.filter(o => status(o) === classification)
if (!objects.length) return []
const result = group(t(`audit.classifications.${classification}`),
[label("guest"), label("destination"), label("lastCopy"), label("backupAge"), label("backupLimit"), label("fact")], objects, o => {
const row = records.find(r => String(r.vmid) === String(o.vmid) && (
o.storage === "any" || r.expected_storage === o.storage ||
r.expected_storage === "any visible destination (no explicit target)" ||
r.storage === o.storage))
const basis = row?.age_policy === "declared recovery objective" ? label("limitDeclared")
: row?.age_policy === "schedule and grace" ? label("limitSchedule")
: typeof row?.age_policy === "string" && row.age_policy.startsWith("fallback;") ? label("limitReference") : ""
const limit = row?.max_age_hours != null && Number.isFinite(Number(row.max_age_hours))
? [auditDuration(Number(row.max_age_hours), locale), basis].filter(Boolean).join(" · ") : "—"
return [guest(o), o.storage === "any" ? label("noDestination") : clean(o.storage),
row?.last_backup ? new Date(Number(row.last_backup) * 1000).toLocaleString(locale) : classification === "unverified" ? t("audit.classifications.unverified") : label("notFound"),
row?.age_hours != null && Number.isFinite(Number(row.age_hours)) ? auditDuration(Number(row.age_hours), locale) : "—",
limit,
reason(o)]
})
// Shared limits retain their origin without repeating it for every guest.
const shared = [1,4,5].filter(index => result.rows.length > 1 && result.rows.every(row => row.cells[index] === result.rows[0].cells[index]))
result.note = shared.map(index => `${result.columns[index]}: ${result.rows[0].cells[index]}`).join(" · ")
result.columns = result.columns.filter((_,index) => !shared.includes(index))
result.rows.forEach(row => { row.cells = row.cells.filter((_,index) => !shared.includes(index)) })
return [result]
})
}
if (f.check_id === "backup.job_results") {
// The PVE task list may contain dozens of repetitions of the same
// failed job. One row per task obscures the useful facts, so retain
// the count, time range, final status and latest UPID per guest.
const merged = new Map<string, Record<string, unknown>>()
for (const item of f.affected) {
// If PVE supplied neither an id field nor a guest-bearing UPID,
// keep the task separate rather than combining unrelated failures.
const identity = clean(item.vmid) || clean(item.upid || item.job)
const key = `${identity}\u0000${clean(item.status)}`
const known = merged.get(key)
const currentMs = auditDate(item.when)?.getTime() ?? 0
if (!known) {
merged.set(key, { ...item, count: 1, first_seen: item.when,
last_seen: item.when, latest_job: item.upid || item.job,
_first_ms: currentMs, _last_ms: currentMs })
continue
}
known.count = Number(known.count || 0) + 1
if (currentMs && (!Number(known._first_ms) || currentMs < Number(known._first_ms))) {
known._first_ms = currentMs
known.first_seen = item.when
}
if (currentMs >= Number(known._last_ms || 0)) {
known._last_ms = currentMs
known.last_seen = item.when
known.latest_job = item.upid || item.job
}
}
const objects = [...merged.values()].sort((a, b) =>
Number(b._last_ms || 0) - Number(a._last_ms || 0))
return objects.length ? [group(t("audit.classifications.warning"),
[label("guest"), label("occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), label("detail"), label("technical")],
objects, o => [o.vmid === undefined || o.vmid === null ? "—" : guest(o),
clean(o.count) || "1", auditInstant(o.first_seen, locale),
auditInstant(o.last_seen, locale), clean(o.status) || reason(o),
clean(o.latest_job) || "—"])] : []
}
// The inventory already presents these events properly: what happened,
// how severe, how often, when it started, when it last happened and
// what the kernel actually said. Six rows reading "sdh · still
// reporting errors" described none of that, so the finding shows the
// same table the inventory does, grouped by device.
if (f.check_id === "hardware.disk_errors") {
const devices = Array.from(new Set(f.affected.map(o => clean(o.name))))
return devices.map(device => group(device,
[t("audit.document.event"), t("audit.document.severity"),
t("audit.document.occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), t("audit.document.detail")],
f.affected.filter(o => clean(o.name) === device),
o => [clean(o.type) || "—",
clean(o.severity) ? t(`audit.classifications.${
o.severity === "critical" ? "critical" : "warning"}`) : "—",
clean(o.count) || "—", auditInstant(o.first_seen, locale), auditInstant(o.last_seen, locale),
clean(o.message) || "—"]))
}
// Lynis repeats a warning once per thing it applies to: ten
// promiscuous interfaces are ten identical records. Printed one per
// row under a heading that already said the same sentence, thirteen
// warnings filled seventeen rows and a column whose only content was
// the identifier repeated from the heading beside it. Collapsed to one
// row per distinct warning, with how many times it was raised and what
// it named where Lynis said so.
if (f.check_id === "security.lynis_warnings") {
const seen = new Map<string, Record<string, unknown>[]>()
for (const o of f.affected) {
const key = `${clean(o.test)}\u0000${clean(o.message)}`
seen.set(key, [...(seen.get(key) || []), o])
}
const entries = [...seen.values()]
const detailed = entries.some(items => items.some(o => clean(o.details)))
// `occurrences` is worded for the middle of a sentence; the column
// header the disk table already uses reads correctly on its own.
const columns = [label("lynisTest"), label("lynisWarning"),
t("audit.document.occurrences")]
return [group(label("records"), detailed ? [...columns, label("detail")] : columns,
entries.map(items => items[0]), (o) => {
const items = seen.get(`${clean(o.test)}\u0000${clean(o.message)}`) || [o]
const cells = [clean(o.test) || "—", clean(o.message) || label("noDescription"),
String(items.length)]
if (!detailed) return cells
const named = [...new Set(items.map(i => clean(i.details)).filter(Boolean))]
return [...cells, named.join(", ") || "—"]
})]
}
return f.affected.length ? [group(label("records"), [label("resource"), label("fact"), label("result")], f.affected, o => {
const details = [clean(o.volume), clean(o.version), o.hours !== undefined ? auditDuration(Number(o.hours), locale) : ""].filter(Boolean).join(" · ")
return [resource(o), [reason(o), details].filter(Boolean).join(" · "), state(o)]
})] : []
}
export function affectedDescription(f: PresentedFinding, t: AuditTranslate): string {
const label = (key: string) => auditLabel(t, key)
if (f.check_id === "backup.guest_coverage") {
const disks = f.affected.filter(o => o.reason_key === "dataExcludedFromBackup").length
const guests = f.affected.length - disks
return [guests ? `${guests} ${label("unscheduled")}` : "", disks ? `${disks} ${label("excludedDisks")}` : ""].filter(Boolean).join(" · ")
}
return f.affected.length ? `${f.affected.length} ${label(f.check_id === "security.lynis_warnings" ? "occurrences" : "records")}` : ""
}
export function resultBreakdown(f: PresentedFinding, t: AuditTranslate): string {
const counts = new Map<string, number>()
for (const item of f.affected) {
const key = clean(item.classification) || f.classification
counts.set(key, (counts.get(key) || 0) + 1)
}
return [...counts].map(([key, count]) => `${count} · ${t(`audit.classifications.${key}`)}`).join(" / ")
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Turns a finding's evidence into something a reader can read.
*
* Checks record evidence in whatever shape suits what they examined:
* some serialise a list of objects, some an object of lists, some write
* a few lines of text. Printing that verbatim shows the reader a JSON
* dump and asks them to parse it — which defeats the purpose of evidence,
* which is to let someone verify a conclusion without trusting it.
*
* The parser recognises those shapes and returns blocks: a table for a
* list of records, labelled pairs for a single record, plain lines for
* the rest. Field names are humanised and values are formatted according
* to what the name says they are — a `_bytes` suffix is a size, `_hours`
* a duration, an `_at` an instant — so the reader sees "1.2 TiB" where
* the check wrote 1319413953331.
*
* Nothing is discarded: text the parser does not recognise is passed
* through as lines, because evidence that has been silently dropped is
* worse than evidence that is ugly.
*/
export type EvidenceBlock =
| { kind: "table"; title?: string; columns: string[]; rows: string[][] }
| { kind: "pairs"; title?: string; entries: Array<[string, string]> }
| { kind: "text"; title?: string; lines: string[] }
/** `max_age_hours` reads as "Max age hours"; `vmid` stays "VMID". */
const ACRONYMS: Record<string, string> = {
vmid: "VMID", id: "ID", cpu: "CPU", pci: "PCI", iommu: "IOMMU",
smart: "SMART", zfs: "ZFS", arc: "ARC", ssh: "SSH", lxc: "LXC",
pve: "PVE", pbs: "PBS", nfs: "NFS", url: "URL", os: "OS", ram: "RAM",
}
export function humanise(field: string): string {
const parts = field.replace(/[_-]+/g, " ").trim().split(/\s+/)
if (parts.length === 0) return field
return parts
.map((word, i) => {
const known = ACRONYMS[word.toLowerCase()]
if (known) return known
return i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
})
.join(" ")
}
function sizeOf(value: number): string {
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
let n = Math.abs(value), i = 0
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
const shown = n >= 100 || i < 2 ? Math.round(n) : Number(n.toFixed(1))
return `${value < 0 ? "-" : ""}${shown} ${units[i]}`
}
export function durationOf(hours: number, locale: string): string {
if (!Number.isFinite(hours) || hours < 0) return "—"
const total = Math.round(hours * 60)
const days = Math.floor(total / 1440), h = Math.floor(total % 1440 / 60)
const unit = (v: number, name: string) => new Intl.NumberFormat(locale, {
style: "unit", unit: name, unitDisplay: "short", maximumFractionDigits: 0,
}).format(v)
return [days ? unit(days, "day") : "", h || days ? unit(h, "hour") : "", unit(total % 60, "minute")].filter(Boolean).join(" ")
}
/**
* Formats one value using what its field name says it is. The name is
* the only type information a check leaves behind, so it is what the
* formatter reads.
*/
export function formatValue(field: string, value: unknown, locale: string,
units?: string): string {
if (value === null || value === undefined || value === "") return "—"
// A recorded `false` is an answer, and it used to render as the same
// dash as "nothing was recorded": a table of seven archives that are
// definitely gone read as seven about which nothing was known.
if (typeof value === "boolean") return value ? "✓" : "✗"
const name = field.toLowerCase()
if (typeof value === "number") {
// The field name is read before the record's declared units: a
// record of sizes still carries a timestamp and a percentage, and
// those are not sizes.
if (name.endsWith("_hours") || name === "hours") return durationOf(value, locale)
if (name.endsWith("_days") || name === "days") return `${Number(value.toFixed(1))} d`
if (name.endsWith("_percent") || name.endsWith("_pct")) {
return `${Number(value.toFixed(1))} %`
}
// A check records instants as epoch seconds under names like
// `last_backup` or `collected_at`, so both the name and the
// magnitude have to agree before a number is shown as a date.
const temporal = /(^|_)(at|time|date|seen|since|backup|run|checked|updated)$/
if (temporal.test(name) && Number.isFinite(value)
&& value > 1_000_000_000 && value < 4_000_000_000) {
return new Date(value * 1000).toLocaleString(locale)
}
if (name.endsWith("_bytes") || name === "bytes" || name.endsWith("_size")
|| units === "bytes") {
return sizeOf(value)
}
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value)
}
if (Array.isArray(value)) {
if (value.length === 0) return "—"
const shown = value.slice(0, 3).map((v) => {
if (v === null || typeof v !== "object") return String(v)
// Inside a cell, name each record by whatever identifies it
// rather than spelling out every field.
const record = v as Record<string, unknown>
const key = ["vmid", "id", "name", "device", "storage", "volume", "job"]
.find((k) => record[k] !== undefined)
return key ? String(record[key])
: Object.entries(record).map(([k, x]) => `${humanise(k)} ${String(x)}`).join(" ")
})
return shown.join(", ") + (value.length > 3 ? ` +${value.length - 3}` : "")
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>)
.map(([k, v]) => `${humanise(k)}: ${formatValue(k, v, locale)}`)
.join(" · ")
}
return String(value)
}
function isRecordList(value: unknown): value is Array<Record<string, unknown>> {
return Array.isArray(value) && value.length > 0 &&
value.every((v) => v !== null && typeof v === "object" && !Array.isArray(v))
}
function tableFrom(records: Array<Record<string, unknown>>, locale: string,
title?: string): EvidenceBlock[] {
// Union of the keys, in first-seen order: records from one check are
// uniform in practice, but a missing key must not shift a column.
const columns: string[] = []
for (const record of records) {
for (const key of Object.keys(record)) {
if (!columns.includes(key)) columns.push(key)
}
}
const cells = records.map((r) => {
const units = typeof r.units === "string" ? r.units : undefined
return Object.fromEntries(
columns.map((c) => [c, formatValue(c, r[c], locale, units)]))
})
// A column holding the same value in every row is a property of the
// whole set, not of any row. Stating it once keeps the table narrow
// enough to read; it only pays off once the table is already wide.
const constant: Array<[string, string]> = []
const varying = columns.filter((c) => {
if (columns.length <= 6 || records.length < 2) return true
const first = cells[0][c]
if (!cells.every((row) => row[c] === first) || first === "—") return true
constant.push([humanise(c), first])
return false
})
const rows = cells.map((row) => varying.map((c) => row[c]))
return constant.length
? [{ kind: "pairs" as const, title, entries: constant },
{ kind: "table" as const, columns: varying.map(humanise), rows }]
: [{ kind: "table" as const, title, columns: varying.map(humanise), rows }]
}
function blocksFromValue(value: unknown, locale: string,
title?: string): EvidenceBlock[] {
if (isRecordList(value)) return tableFrom(value, locale, title)
if (Array.isArray(value)) {
return value.length
? [{ kind: "text", title, lines: value.map((v) => formatValue("", v, locale)) }]
: []
}
if (value !== null && typeof value === "object") {
const blocks: EvidenceBlock[] = []
const pairs: Array<[string, string]> = []
const record = value as Record<string, unknown>
const units = typeof record.units === "string" ? record.units : undefined
for (const [key, inner] of Object.entries(record)) {
// A nested list of records earns its own table under its own name;
// everything else stays a labelled pair.
if (isRecordList(inner)) {
blocks.push(...tableFrom(inner, locale, humanise(key)))
} else {
pairs.push([humanise(key), formatValue(key, inner, locale, units)])
}
}
if (pairs.length) blocks.unshift({ kind: "pairs", title, entries: pairs })
return blocks
}
return [{ kind: "text", title, lines: [formatValue("", value, locale)] }]
}
/**
* Text evidence: lines like `label:` introduce the indented lines under
* them, which is the shape checks write by hand.
*/
function blocksFromText(text: string, locale: string): EvidenceBlock[] {
const lines = text.split("\n")
const blocks: EvidenceBlock[] = []
let title: string | undefined
let buffer: string[] = []
const flush = () => {
const kept = buffer.filter((l) => l.trim())
const joined = kept.join("\n").trim()
// A section introduced by a heading gets the same treatment as
// evidence that is JSON from the first character.
let sectionTitle = title
let source = joined
if (joined && !/^[[{]/.test(joined)) {
const at = joined.search(/:\s*[[{]/)
// Only a short prefix is a label; a paragraph that happens to
// mention a bracket is prose.
if (at > 0 && at < 80) {
sectionTitle = title || joined.slice(0, at).trim()
source = joined.slice(joined.indexOf(joined[at] === ":" ? ":" : ":", at) + 1).trim()
}
}
const split = source ? splitLeadingJson(source) : null
if (split) {
const [value, rest] = split
blocks.push(...blocksFromValue(value, locale, sectionTitle))
if (rest) blocks.push({ kind: "text", lines: rest.split("\n") })
buffer = []
return
}
if (kept.length || title) blocks.push({ kind: "text", title, lines: kept })
buffer = []
}
for (const line of lines) {
const heading = /^(\S[^:]*):\s*$/.exec(line)
if (heading) {
flush()
title = heading[1]
continue
}
buffer.push(line.replace(/^\s{1,4}/, ""))
}
flush()
return blocks.filter((b) => b.kind !== "text" || b.lines.length || b.title)
}
/**
* Splits a leading JSON document from whatever text follows it, by
* balancing brackets outside of strings. Checks routinely serialise
* their records and then add a line qualifying them, and both halves
* are evidence.
*/
export function splitLeadingJson(text: string): [unknown, string] | null {
const open = text[0]
if (open !== "{" && open !== "[") return null
const close = open === "{" ? "}" : "]"
let depth = 0, inString = false, escaped = false, end = -1
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (escaped) { escaped = false; continue }
if (c === "\\") { escaped = true; continue }
if (c === '"') { inString = !inString; continue }
if (inString) continue
if (c === open) depth++
else if (c === close && --depth === 0) { end = i + 1; break }
}
if (end < 0) return null
try {
return [JSON.parse(text.slice(0, end)), text.slice(end).trim()]
} catch {
return null
}
}
/** Parses one finding's evidence into blocks a reader can read. */
export function parseEvidence(evidence: string | null,
locale = "en"): EvidenceBlock[] {
if (!evidence) return []
const text = evidence.trim()
if (!text) return []
const split = splitLeadingJson(text)
if (split) {
const [value, rest] = split
const blocks = blocksFromValue(value, locale)
return rest ? blocks.concat(blocksFromText(rest, locale)) : blocks
}
return blocksFromText(text, locale)
}
+556
View File
@@ -0,0 +1,556 @@
/**
* Inline SVG diagrams for the audit report.
*
* The inventory already resolves how the pieces of a node connect; a
* diagram is what makes those relations legible at a glance. Drawn as
* SVG with no dependency so the document stays self-contained and prints
* as vector rather than as a screenshot.
*
* Colours come from the report stylesheet's palette so a diagram reads
* as part of the document and not as an embedded picture.
*/
import { esc } from "./report-shell"
const INK = "#0f172a"
const MUTED = "#64748b"
const LINE = "#94a3b8"
const FILL = "#f8fafc"
const EDGE = "#e2e8f0"
const ACCENT = "#06b6d4"
const WARN = "#ca8a04"
/**
* Approximate width of a string at a given size.
*
* SVG has no layout: text drawn wider than its box simply spills over
* it. Measuring properly needs the font metrics, which are not available
* while composing the document, so widths are estimated per character
* class — narrow, wide and everything else — which is close enough to
* decide where to cut.
*/
function textWidth(text: string, size: number, bold = false): number {
let units = 0
for (const c of text) {
if ("iljI.,:;'|! ".includes(c)) units += 0.30
else if ("mwMW@".includes(c)) units += 0.92
else if (c >= "A" && c <= "Z") units += 0.68
else if (c >= "0" && c <= "9") units += 0.56
else units += 0.54
}
return units * size * (bold ? 1.06 : 1)
}
/** Cuts a label to what fits, marking the cut. */
function fit(text: string, width: number, size: number, bold = false): string {
if (textWidth(text, size, bold) <= width) return text
let out = text
while (out.length > 1 && textWidth(out + "…", size, bold) > width) {
out = out.slice(0, -1)
}
return out.trimEnd() + "…"
}
/**
* Processor models carry trademark noise and a clock the diagram states
* elsewhere. The part a reader identifies the chip by is the family and
* the model number.
*/
export function shortenCpu(model: string): string {
return (model || "")
.replace(/\((?:R|TM|r|tm)\)/g, "")
.replace(/\b(CPU|Processor)\b/gi, "")
.replace(/\s*@.*$/, "")
.replace(/\s{2,}/g, " ")
.trim()
}
interface Node { id: string; label: string; sub?: string; tone?: "plain" | "accent" | "warn" }
function box(x: number, y: number, w: number, h: number, n: Node): string {
const stroke = n.tone === "accent" ? ACCENT : n.tone === "warn" ? WARN : EDGE
const inner = w - 12
return `<g>
<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="5"
fill="${FILL}" stroke="${stroke}" stroke-width="1.5"/>
<text x="${x + w / 2}" y="${y + (n.sub ? h / 2 - 3 : h / 2 + 4)}" text-anchor="middle"
font-size="11" font-weight="600" fill="${INK}">${esc(fit(n.label, inner, 11, true))}</text>
${n.sub ? `<text x="${x + w / 2}" y="${y + h / 2 + 11}" text-anchor="middle"
font-size="9" fill="${MUTED}">${esc(fit(n.sub, inner, 9))}</text>` : ""}
</g>`
}
function arrow(x1: number, y1: number, x2: number, y2: number): string {
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${LINE}"
stroke-width="1.4" marker-end="url(#pmx-arrow)"/>`
}
const DEFS = `<defs>
<marker id="pmx-arrow" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="${LINE}"/>
</marker>
</defs>`
function svg(width: number, height: number, body: string): string {
// A viewBox with no fixed width lets the diagram scale to the column on
// screen and to the page when printed, without a second layout.
return `<svg viewBox="0 0 ${width} ${height}" width="100%" role="img"
preserveAspectRatio="xMidYMin meet"
style="display:block;height:auto">${DEFS}${body}</svg>`
}
/**
* Network path: physical interfaces, the bond that groups them when one
* exists, each bridge and the guests attached to it. This is the chain a
* reader would otherwise reconstruct by hand from three separate lists.
*/
export function networkDiagram(
bridges: Record<string, any>,
guests: Array<{ vmid: number; name: string; interfaces: Array<{ bridge: string }> }>,
labels: { nic: string; bond: string; bridge: string; guests: string },
): string {
const entries = Object.entries(bridges || {})
if (entries.length === 0) return ""
const COL_W = 132, BOX_H = 34, GAP_Y = 12, PAD = 12
const rows: Array<{ nics: Node[]; bond: Node | null; bridge: Node; count: number }> = []
for (const [id, b] of entries) {
const hops = (b.uplink || []) as Array<{ kind: string; id: string; mode?: string }>
const bond = hops.find((h) => h.kind === "bond")
const nics = hops.filter((h) => h.kind === "nic")
const attached = guests.filter((g) =>
(g.interfaces || []).some((n) => n.bridge === id)).length
rows.push({
nics: nics.length ? nics.map((n) => ({ id: n.id, label: n.id }))
: [{ id: `${id}-none`, label: "—", tone: "warn" as const }],
bond: bond ? { id: bond.id, label: bond.id, sub: bond.mode, tone: "accent" as const } : null,
bridge: { id, label: id, tone: "accent" as const },
count: attached,
})
}
// A host with no bond has no bond column: keeping the caption over an
// empty lane invites the reader to look for something that is not
// there, and leaves the diagram a quarter wider than it needs to be.
const hasBond = rows.some((r) => r.bond)
const bridgeCol = hasBond ? 2 : 1
const guestsCol = bridgeCol + 1
const height = PAD * 2 + rows.reduce((h, r) =>
h + Math.max(r.nics.length, 1) * (BOX_H + GAP_Y), 0)
const width = COL_W * (guestsCol + 1) + PAD * 2
let y = PAD
const parts: string[] = []
// Column captions
const captions = hasBond
? [labels.nic, labels.bond, labels.bridge, labels.guests]
: [labels.nic, labels.bridge, labels.guests]
parts.push(captions.map((c, i) =>
`<text x="${PAD + COL_W * i + COL_W / 2}" y="${PAD - 2}" text-anchor="middle"
font-size="9" font-weight="700" letter-spacing="0.06em"
fill="${MUTED}">${esc(c.toUpperCase())}</text>`).join(""))
y += 8
for (const row of rows) {
const block = Math.max(row.nics.length, 1) * (BOX_H + GAP_Y)
const midY = y + block / 2 - BOX_H / 2
row.nics.forEach((n, i) => {
const ny = y + i * (BOX_H + GAP_Y)
parts.push(box(PAD, ny, COL_W - 20, BOX_H, n))
const target = row.bond ? PAD + COL_W : PAD + COL_W * bridgeCol
parts.push(arrow(PAD + COL_W - 20, ny + BOX_H / 2, target, midY + BOX_H / 2))
})
if (row.bond) {
parts.push(box(PAD + COL_W, midY, COL_W - 20, BOX_H, row.bond))
parts.push(arrow(PAD + COL_W * bridgeCol - 20, midY + BOX_H / 2,
PAD + COL_W * bridgeCol, midY + BOX_H / 2))
}
parts.push(box(PAD + COL_W * bridgeCol, midY, COL_W - 20, BOX_H, row.bridge))
parts.push(arrow(PAD + COL_W * guestsCol - 20, midY + BOX_H / 2,
PAD + COL_W * guestsCol, midY + BOX_H / 2))
parts.push(box(PAD + COL_W * guestsCol, midY, COL_W - 20, BOX_H,
{ id: `${row.bridge.id}-g`, label: String(row.count), sub: labels.guests }))
y += block
}
return svg(width, height + 8, parts.join(""))
}
/**
* Where each guest's disks live, and what protects them. Storages and
* backup destinations are drawn once with the guests that depend on
* them, which is what turns two lists into a dependency picture.
*/
export function storageDiagram(
guests: Array<{
vmid: number; name: string
disks: Array<{ storage: string | null }>
backups: Array<{ storage: string }>
}>,
labels: { guests: string; storage: string; backup: string; unprotected: string },
): string {
const storages = new Map<string, number>()
const destinations = new Map<string, number>()
let unprotected = 0
for (const g of guests || []) {
for (const storage of new Set((g.disks || []).map(d => d.storage).filter(Boolean))) {
if (storage) storages.set(storage, (storages.get(storage) || 0) + 1)
}
if ((g.backups || []).length === 0) unprotected += 1
for (const storage of new Set((g.backups || []).map(b => b.storage))) {
destinations.set(storage, (destinations.get(storage) || 0) + 1)
}
}
if (storages.size === 0) return ""
const COL_W = 168, BOX_H = 34, GAP_Y = 12, PAD = 12
const left = [...storages.entries()].sort()
const right = [...destinations.entries()].sort()
const lanes = Math.max(left.length, right.length + (unprotected ? 1 : 0), 1)
const height = PAD * 2 + 10 + lanes * (BOX_H + GAP_Y)
const width = COL_W * 3 + PAD * 2
const parts: string[] = []
parts.push([labels.storage, labels.guests, labels.backup].map((c, i) =>
`<text x="${PAD + COL_W * i + COL_W / 2}" y="${PAD - 2}" text-anchor="middle"
font-size="9" font-weight="700" letter-spacing="0.06em"
fill="${MUTED}">${esc(c.toUpperCase())}</text>`).join(""))
const centreY = PAD + 8 + (lanes * (BOX_H + GAP_Y)) / 2 - BOX_H / 2
parts.push(box(PAD + COL_W, centreY, COL_W - 24, BOX_H, {
id: "guests", label: String((guests || []).length), sub: labels.guests, tone: "accent",
}))
left.forEach(([name, count], i) => {
const y = PAD + 8 + i * (BOX_H + GAP_Y)
parts.push(box(PAD, y, COL_W - 24, BOX_H, { id: name, label: name, sub: `${count}` }))
parts.push(arrow(PAD + COL_W - 24, y + BOX_H / 2, PAD + COL_W, centreY + BOX_H / 2))
})
right.forEach(([name, count], i) => {
const y = PAD + 8 + i * (BOX_H + GAP_Y)
parts.push(box(PAD + COL_W * 2, y, COL_W - 24, BOX_H,
{ id: name, label: name, sub: `${count}` }))
parts.push(arrow(PAD + COL_W * 2 - 24, centreY + BOX_H / 2, PAD + COL_W * 2, y + BOX_H / 2))
})
if (unprotected > 0) {
const y = PAD + 8 + right.length * (BOX_H + GAP_Y)
parts.push(box(PAD + COL_W * 2, y, COL_W - 24, BOX_H, {
id: "unprotected", label: String(unprotected), sub: labels.unprotected,
}))
parts.push(arrow(PAD + COL_W * 2 - 24, centreY + BOX_H / 2, PAD + COL_W * 2, y + BOX_H / 2))
}
return svg(width, height, parts.join(""))
}
/**
* Findings per area and state, as a stacked bar. A table of counts is
* exact but does not show where the weight of the assessment sits.
*/
export function findingsChart(
byArea: Record<string, Record<string, number>>,
areaLabel: (a: string) => string,
stateColor: Record<string, string>,
order: string[],
): string {
const areas = Object.keys(byArea).sort()
if (areas.length === 0) return ""
const ROW_H = 26, PAD = 12, LABEL_W = 128, BAR_W = 320
const max = Math.max(...areas.map((a) =>
order.reduce((s, st) => s + (byArea[a][st] || 0), 0)), 1)
const height = PAD * 2 + areas.length * ROW_H
const width = LABEL_W + BAR_W + PAD * 2 + 30
const parts = areas.map((a, i) => {
const y = PAD + i * ROW_H
let x = LABEL_W
const total = order.reduce((s, st) => s + (byArea[a][st] || 0), 0)
const segs = order.filter((st) => byArea[a][st]).map((st) => {
const w = (byArea[a][st] / max) * BAR_W
const seg = `<rect x="${x}" y="${y + 5}" width="${w}" height="14" rx="2"
fill="${stateColor[st] || LINE}"><title>${esc(st)}: ${byArea[a][st]}</title></rect>`
x += w
return seg
}).join("")
return `<text x="${LABEL_W - 8}" y="${y + 16}" text-anchor="end" font-size="10"
fill="${INK}">${esc(areaLabel(a))}</text>${segs}
<text x="${x + 6}" y="${y + 16}" font-size="10" fill="${MUTED}">${total}</text>`
})
return svg(width, height, parts.join(""))
}
/**
* How the node is built: the chassis and what is seated in it.
*
* Read left to right as the machine is assembled — processor and memory
* on the board, the controllers the board exposes, and what hangs off
* each controller. Drawn as nested frames rather than as a graph,
* because containment is what the reader is being told: this disk is
* behind that controller, these modules sit in those slots.
*/
export function nodeArchitectureDiagram(
hw: any,
identity: { node?: string; pve_version?: string },
labels: {
chassis: string; processor: string; memory: string
controllers: string; disks: string; adapters: string
slotsUsed: string; cores: string; threads: string; empty: string
},
): string {
if (!hw) return ""
const PAD = 14, W = 860
const parts: string[] = []
let y = PAD + 18
const frame = (title: string, x: number, w: number, top: number, h: number) => {
parts.push(`<rect x="${x}" y="${top}" width="${w}" height="${h}" rx="7"
fill="none" stroke="${EDGE}" stroke-width="1.5"/>
<rect x="${x + 12}" y="${top - 6}" width="${title.length * 6.2 + 12}" height="12"
fill="#ffffff"/>
<text x="${x + 18}" y="${top + 3}" font-size="9" font-weight="700"
letter-spacing="0.08em" fill="${MUTED}">${esc(title.toUpperCase())}</text>`)
}
const chip = (x: number, top: number, w: number, h: number,
title: string, lines: string[], tone: "plain" | "accent" | "warn" = "plain") => {
const stroke = tone === "accent" ? ACCENT : tone === "warn" ? WARN : EDGE
const inner = w - 12
parts.push(`<rect x="${x}" y="${top}" width="${w}" height="${h}" rx="5"
fill="${FILL}" stroke="${stroke}" stroke-width="1.5"/>
<text x="${x + w / 2}" y="${top + 15}" text-anchor="middle" font-size="10.5"
font-weight="600" fill="${INK}">${esc(fit(title, inner, 10.5, true))}</text>` +
lines.map((l, i) => `<text x="${x + w / 2}" y="${top + 29 + i * 11}"
text-anchor="middle" font-size="9" fill="${MUTED}">${esc(fit(l, inner, 9))}</text>`).join(""))
}
// Board: processor and memory slots.
const cpu = hw.cpu || {}
const mem = hw.memory || {}
const modules: any[] = mem.modules || []
const slots = mem.slots || modules.length
const boardH = 76
frame(labels.chassis, PAD, W - PAD * 2, y, boardH)
const model = shortenCpu(cpu.model) || labels.processor
const cpuW = Math.min(250, Math.max(150, textWidth(model, 10.5, true) + 20))
chip(PAD + 14, y + 14, cpuW, 48, model, [
`${cpu.sockets || 1} × ${cpu.cores_per_socket || "?"} ${labels.cores}`,
`${cpu.threads || "?"} ${labels.threads}`,
], "accent")
// One tile per slot, so an empty slot is as visible as a filled one.
// The tiles share what the processor leaves, gaps included, so a board
// with many slots narrows them rather than dropping the last one.
const count = Math.max(slots, modules.length, 1)
const slotArea = W - PAD * 2 - cpuW - 42
const tileW = Math.min(96, Math.max(34, (slotArea - (count - 1) * 6) / count))
for (let i = 0; i < count; i++) {
const m = modules[i]
const x = PAD + 28 + cpuW + i * (tileW + 6)
if (x + tileW > W - PAD - 8) break
chip(x, y + 14, tileW, 48, m ? String(m.size || "") : labels.empty,
m ? [String(m.type || ""), String(m.speed || "")] : [],
m ? "plain" : "warn")
}
parts.push(`<text x="${W - PAD - 6}" y="${y + boardH + 12}" text-anchor="end"
font-size="9" fill="${MUTED}">${esc(labels.memory)}: ${mem.populated || 0}/${slots || "?"} ${esc(labels.slotsUsed)}</text>`)
y += boardH + 26
// Controllers, with what each one carries underneath.
const controllers: any[] = hw.controllers || []
const disks: any[] = hw.disks || []
const adapters: any[] = hw.adapters || []
const byBus = new Map<string, any[]>()
for (const d of disks) {
const bus = d.bus || labels.disks
byBus.set(bus, [...(byBus.get(bus) || []), d])
}
const groups: Array<{ title: string; sub: string; items: string[] }> = []
for (const [bus, list] of [...byBus.entries()].sort()) {
const kind = bus === "nvme" ? "Non-Volatile memory controller"
: bus === "sata" ? "SATA controller" : ""
const count = controllers.filter((c) => c.class === kind).length
groups.push({
title: bus.toUpperCase(),
sub: count ? `${count} ${labels.controllers.toLowerCase()}` : labels.controllers.toLowerCase(),
items: list.map((d) => `${d.name} · ${d.rotational ? "HDD" : "SSD"}`),
})
}
if (adapters.length) {
groups.push({
title: labels.adapters.toUpperCase(),
sub: `${adapters.length}`,
items: adapters.map((a) =>
`${a.name}${a.speed_mbps ? ` · ${a.speed_mbps >= 1000
? `${a.speed_mbps / 1000}G` : `${a.speed_mbps}M`}` : ""}`),
})
}
if (groups.length === 0) return svg(W, y + PAD, parts.join(""))
const colW = (W - PAD * 2 - (groups.length - 1) * 10) / groups.length
const rows = Math.max(...groups.map((g) => g.items.length))
const groupH = 34 + Math.min(rows, 8) * 15 + 10
groups.forEach((g, i) => {
const x = PAD + i * (colW + 10)
parts.push(`<rect x="${x}" y="${y}" width="${colW}" height="${groupH}" rx="6"
fill="none" stroke="${EDGE}" stroke-width="1.5"/>
<rect x="${x}" y="${y}" width="${colW}" height="24" rx="6" fill="${FILL}"/>
<text x="${x + colW / 2}" y="${y + 16}" text-anchor="middle" font-size="10"
font-weight="700" fill="${INK}">${esc(fit(g.title, colW - 12, 10, true))}</text>` +
g.items.slice(0, 8).map((item, j) =>
`<text x="${x + 10}" y="${y + 39 + j * 15}" font-size="9.5"
fill="${MUTED}">${esc(fit(item, colW - 20, 9.5))}</text>`).join("") +
(g.items.length > 8
? `<text x="${x + 10}" y="${y + 39 + 8 * 15}" font-size="9" fill="${MUTED}">+${g.items.length - 8}</text>`
: ""))
// Tie each group back to the board it hangs from.
parts.push(`<line x1="${x + colW / 2}" y1="${y - 12}" x2="${x + colW / 2}" y2="${y}"
stroke="${LINE}" stroke-width="1.2"/>`)
})
return svg(W, y + groupH + PAD, parts.join(""))
}
/**
* Cluster membership: every configured node, which one this report
* describes, and whether the node currently sees it.
*/
export function clusterDiagram(
cluster: any,
labels: { thisNode: string; unreachable: string; links: string },
): string {
if (!cluster || !(cluster.nodes || []).length) return ""
const nodes: any[] = cluster.nodes
const PAD = 16, BOX_W = 132, BOX_H = 46, GAP = 16
const perRow = Math.min(nodes.length, 5)
const rowCount = Math.ceil(nodes.length / perRow)
const width = PAD * 2 + perRow * BOX_W + (perRow - 1) * GAP
const busY = PAD + 22
const height = busY + 26 + rowCount * (BOX_H + 26) + PAD
const parts: string[] = []
// The corosync ring, drawn as the bus every node attaches to.
parts.push(`<line x1="${PAD}" y1="${busY}" x2="${width - PAD}" y2="${busY}"
stroke="${ACCENT}" stroke-width="2"/>
<text x="${PAD}" y="${busY - 7}" font-size="9" font-weight="700"
letter-spacing="0.08em" fill="${MUTED}">${esc(
`${cluster.name} · ${cluster.links || 1} ${labels.links}`.toUpperCase())}</text>`)
nodes.forEach((n, i) => {
const row = Math.floor(i / perRow), col = i % perRow
const x = PAD + col * (BOX_W + GAP)
const y = busY + 26 + row * (BOX_H + 26)
parts.push(`<line x1="${x + BOX_W / 2}" y1="${busY}" x2="${x + BOX_W / 2}" y2="${y}"
stroke="${LINE}" stroke-width="1.2"/>`)
const offline = n.online === false
const stroke = offline ? WARN : n.local ? ACCENT : EDGE
parts.push(`<rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6"
fill="${FILL}" stroke="${stroke}" stroke-width="${n.local ? 2 : 1.5}"/>
<text x="${x + BOX_W / 2}" y="${y + 19}" text-anchor="middle" font-size="11"
font-weight="600" fill="${INK}">${esc(n.name)}</text>
<text x="${x + BOX_W / 2}" y="${y + 32}" text-anchor="middle" font-size="9"
fill="${MUTED}">${esc(n.ring0_addr || "")}</text>
<text x="${x + BOX_W / 2}" y="${y + 42}" text-anchor="middle" font-size="8.5"
fill="${offline ? WARN : MUTED}">${esc(
offline ? labels.unreachable : n.local ? labels.thisNode : `id ${n.nodeid}`)}</text>`)
})
return svg(width, height, parts.join(""))
}
/**
* Latency over the reported window, one line per target.
*
* Averages say what is normal; the shape says whether it stayed that
* way. A table of min/avg/max cannot show a link that was fine except
* for twenty minutes, which is the reading the chart exists for.
*/
export function latencyChart(
targets: Array<{
target: string; label?: string
series: Array<{ t: number; v: number; max?: number | null }>
}>,
labels: { ms: string; hours: string },
): string {
const drawn = targets.filter((t) => (t.series || []).length > 1)
if (drawn.length === 0) return ""
const PAD = 12, LEFT = 46, BOTTOM = 24, W = 760, H = 210
const plotW = W - LEFT - PAD, plotH = H - PAD - BOTTOM
const all = drawn.flatMap((t) => t.series)
const times = all.map((s) => s.t)
const t0 = Math.min(...times), t1 = Math.max(...times)
// The ceiling covers the peaks, so the chart cannot disagree with the
// maximum the table reports.
const peak = Math.max(...all.map((s) => Math.max(s.v, s.max ?? 0)), 1)
const top = niceCeiling(peak)
const colors = [ACCENT, "#7c3aed", "#ca8a04"]
const x = (t: number) => LEFT + (t1 === t0 ? plotW : ((t - t0) / (t1 - t0)) * plotW)
const y = (v: number) => PAD + plotH - (Math.min(v, top) / top) * plotH
const parts: string[] = []
for (let i = 0; i <= 4; i++) {
const value = (top / 4) * i
const gy = y(value)
parts.push(`<line x1="${LEFT}" y1="${gy}" x2="${W - PAD}" y2="${gy}"
stroke="${EDGE}" stroke-width="1"/>
<text x="${LEFT - 6}" y="${gy + 3}" text-anchor="end" font-size="9"
fill="${MUTED}">${axisLabel(value)}</text>`)
}
parts.push(`<text x="${PAD - 4}" y="${PAD + 4}" font-size="9" fill="${MUTED}">${esc(labels.ms)}</text>`)
drawn.forEach((t, i) => {
const color = colors[i % colors.length]
const points = t.series
// The band spans each sample's peak, the line its average: one shows
// what the link usually does, the other what it did at worst.
if (points.some((s) => typeof s.max === "number")) {
const area = points.map((s, j) =>
`${j === 0 ? "M" : "L"}${x(s.t).toFixed(1)} ${y(s.max ?? s.v).toFixed(1)}`).join(" ")
const back = points.slice().reverse().map((s) =>
`L${x(s.t).toFixed(1)} ${y(s.v).toFixed(1)}`).join(" ")
parts.push(`<path d="${area} ${back} Z" fill="${color}" fill-opacity="0.13"
stroke="none"/>`)
}
const line = points
.map((s, j) => `${j === 0 ? "M" : "L"}${x(s.t).toFixed(1)} ${y(s.v).toFixed(1)}`)
.join(" ")
parts.push(`<path d="${line}" fill="none" stroke="${color}"
stroke-width="1.4" stroke-linejoin="round"/>`)
const legendX = LEFT + i * 150
parts.push(`<rect x="${legendX}" y="${H - 13}" width="9" height="3" rx="1.5"
fill="${color}"/>
<text x="${legendX + 14}" y="${H - 9}" font-size="9"
fill="${MUTED}">${esc(fit(t.label || t.target, 130, 9))}</text>`)
})
const span = Math.max(1, Math.round((t1 - t0) / 3600))
parts.push(`<text x="${W - PAD}" y="${H - 9}" text-anchor="end" font-size="9"
fill="${MUTED}">${esc(`${span} ${labels.hours}`)}</text>`)
return svg(W, H, parts.join(""))
}
/** A ceiling that divides into four readable gridlines. */
function niceCeiling(peak: number): number {
const magnitude = Math.pow(10, Math.floor(Math.log10(peak)))
for (const step of [1, 2, 2.5, 5, 10]) {
const candidate = step * magnitude
if (candidate >= peak) return candidate
}
return 10 * magnitude
}
function axisLabel(value: number): string {
if (value === 0) return "0"
// Gridlines land on quarters of the ceiling, so halves are common;
// rounding them away would put a label where the line is not.
return String(Number(value.toFixed(Number.isInteger(value) ? 0 : 1)))
}
+496
View File
@@ -0,0 +1,496 @@
/**
* Shared shell for ProxMenux Monitor reports.
*
* The SMART, latency and audit reports are one family: same header with
* the product mark and a report identifier, numbered sections, the same
* cards, tables and callouts, the same dark action bar on screen that
* disappears when printing. This module holds that common language so a
* new report joins the family instead of inventing its own.
*
* The stylesheet is the one the SMART report established, kept verbatim
* so the two documents are indistinguishable side by side.
*/
export const REPORT_CSS = ` * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
@page { margin: 10mm; size: A4; }
/* === SCREEN: responsive layout === */
@media screen {
body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; overflow-x: hidden; }
}
@media screen and (max-width: 640px) {
body { padding: 16px; padding-top: 64px; }
.grid-4 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr; }
.rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.rpt-header-right { text-align: left; }
.exec-box { flex-wrap: wrap; }
.card-c .card-value { font-size: 16px; }
}
/* === PRINT: force desktop A4 layout from any device === */
@media print {
html, body { margin: 0 !important; padding: 0 !important; width: 100% !important; max-width: none !important; }
.no-print { display: none !important; }
.top-bar { display: none !important; }
.page-break { page-break-before: always; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { font-size: 11px; padding-top: 0 !important; }
/* Force desktop grid layout regardless of viewport */
.grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr !important; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr !important; }
.grid-2 { grid-template-columns: 1fr 1fr !important; }
.rpt-header { flex-direction: row !important; align-items: center !important; }
.rpt-header-right { text-align: right !important; }
.exec-box { flex-wrap: nowrap !important; }
.card-c .card-value { font-size: 20px !important; }
/* Page break control */
.section { page-break-inside: avoid; break-inside: avoid; margin-bottom: 15px; }
.exec-box { page-break-inside: avoid; break-inside: avoid; }
.card { page-break-inside: avoid; break-inside: avoid; }
.grid-2, .grid-3, .grid-4 { page-break-inside: avoid; break-inside: avoid; }
.section-title { page-break-after: avoid; break-after: avoid; }
.attr-tbl tr { page-break-inside: avoid; break-inside: avoid; }
.attr-tbl thead { display: table-header-group; }
.rpt-footer { page-break-inside: avoid; break-inside: avoid; margin-top: 20px; }
svg { max-width: 100%; height: auto; }
/* Darken light grays for PDF readability */
.rpt-header-left p, .rpt-header-right { color: #374151; }
.rpt-header-right .rid { color: #4b5563; }
.exec-text p { color: #374151; }
.card-label { color: #4b5563; }
.rpt-footer { color: #4b5563; }
[style*="color:#64748b"] { color: #374151 !important; }
[style*="color:#94a3b8"] { color: #4b5563 !important; }
[style*="color: #64748b"] { color: #374151 !important; }
[style*="color: #94a3b8"] { color: #4b5563 !important; }
[style*="color:#16a34a"], [style*="color: #16a34a"] { color: #16a34a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
[style*="color:#dc2626"] { color: #dc2626 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
[style*="color:#ca8a04"] { color: #ca8a04 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.health-ring, .card-value, .f-tag { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
/* Top bar for screen only */
.top-bar {
position: fixed; top: 0; left: 0; right: 0; background: #0f172a; color: #e2e8f0;
padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; z-index: 100;
font-size: 13px;
}
.top-bar-left { display: flex; align-items: center; gap: 12px; }
.top-bar-title { font-weight: 600; }
.top-bar-subtitle { font-size: 11px; color: #94a3b8; }
.top-bar button {
background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.top-bar button:hover { background: #0891b2; }
.top-bar .btn-group { display: flex; gap: 8px; }
.top-bar button svg { width: 18px; height: 18px; display: block; }
/* Header */
.rpt-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 0; border-bottom: 3px solid #0f172a; margin-bottom: 22px;
}
.rpt-header-left { display: flex; align-items: center; gap: 14px; }
.rpt-header-left img { height: 44px; width: auto; }
.rpt-header-left h1 { font-size: 22px; font-weight: 700; color: #0f172a; }
.rpt-header-left p { font-size: 11px; color: #64748b; }
.rpt-header-right { text-align: right; font-size: 11px; color: #64748b; line-height: 1.6; }
.rpt-header-right .rid { font-family: monospace; font-size: 10px; color: #94a3b8; }
/* Sections */
.section { margin-bottom: 22px; }
.section-title {
font-size: 14px; font-weight: 700; color: #0f172a; text-transform: uppercase;
letter-spacing: 0.05em; padding-bottom: 5px; border-bottom: 2px solid #e2e8f0; margin-bottom: 12px;
}
/* Executive summary */
.exec-box {
display: flex; align-items: flex-start; gap: 20px; padding: 20px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px;
}
.health-ring {
width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
align-items: center; justify-content: center; border: 4px solid; flex-shrink: 0;
}
.health-icon { font-size: 32px; line-height: 1; }
.health-lbl { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; margin-top: 4px; }
.exec-text { flex: 1; min-width: 200px; }
.exec-text h3 { font-size: 16px; margin-bottom: 4px; }
.exec-text p { font-size: 12px; color: #64748b; line-height: 1.5; }
/* Grids */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
.card-c { text-align: center; }
.card-c .card-value { font-size: 20px; font-weight: 800; }
/* Tags */
.f-tag { font-size: 9px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
/* Tables */
.attr-tbl { width: 100%; border-collapse: collapse; font-size: 11px; }
.attr-tbl th { text-align: left; padding: 6px 4px; font-size: 10px; color: #64748b; font-weight: 600; border-bottom: 2px solid #e2e8f0; background: #f1f5f9; }
.attr-tbl td { padding: 5px 4px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
.attr-tbl tr:hover { background: #f8fafc; }
.attr-tbl .col-name { word-break: break-word; }
.attr-tbl .col-raw { font-family: monospace; font-size: 10px; }
/* Attribute explanation rows: full-width below the data row */
.attr-explain-row td { padding-top: 0 !important; }
.attr-explain-row:hover { background: transparent; }
/* Recommendations */
.rec-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px; border-radius: 6px; margin-bottom: 8px; }
.rec-icon { font-size: 18px; flex-shrink: 0; width: 24px; text-align: center; }
.rec-item strong { display: block; margin-bottom: 2px; }
.rec-item p { font-size: 12px; color: #64748b; margin: 0; }
.rec-ok { background: #dcfce7; border: 1px solid #86efac; }
.rec-ok .rec-icon { color: #16a34a; }
.rec-warn { background: #fef3c7; border: 1px solid #fcd34d; }
.rec-warn .rec-icon { color: #ca8a04; }
.rec-critical { background: #fee2e2; border: 1px solid #fca5a5; }
.rec-critical .rec-icon { color: #dc2626; }
.rec-info { background: #e0f2fe; border: 1px solid #7dd3fc; }
.rec-info .rec-icon { color: #0284c7; }
/* Footer */
.rpt-footer {
margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0;
display: flex; justify-content: space-between; font-size: 10px; color: #94a3b8;
}
/* NOTE: No mobile-specific layout overrides — print layout is always A4/desktop
regardless of the device generating the PDF. The @media print block above
handles all necessary print adjustments. */`
/**
* Additions the assessment document needs on top of the shared sheet:
* state chips, findings, evidence and a frame for diagrams. Kept apart
* from REPORT_CSS so the inherited stylesheet stays byte-identical to
* the one the other reports use.
*/
export const REPORT_CSS_AUDIT = `
.diagram { border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px;
background: #ffffff; margin: 6px 0 14px; overflow-x: auto; }
.diagram-note { font-size: 10.5px; color: #64748b; margin: 0 0 10px; }
.chip { display: inline-block; padding: 2px 9px; border-radius: 999px;
font-size: 10px; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase; white-space: nowrap; }
.chip.critical { background: #fee2e2; color: #991b1b; }
.chip.warning { background: #fef3c7; color: #92400e; }
.chip.observation { background: #dbeafe; color: #1e40af; }
.chip.conformant { background: #dcfce7; color: #166534; }
.chip.accepted { background: #e0e7ff; color: #3730a3; }
.chip.unverified, .chip.not_applicable { background: #f1f5f9; color: #475569; }
.finding { border: 1px solid #e2e8f0; border-left: 3px solid #cbd5e1;
border-radius: 6px; padding: 11px 13px; margin-bottom: 9px;
page-break-inside: avoid; break-inside: avoid; }
.finding.critical { border-left-color: #dc2626; }
.finding.warning { border-left-color: #ca8a04; }
.finding.observation { border-left-color: #3b82f6; }
.finding.conformant { border-left-color: #16a34a; }
.finding.accepted { border-left-color: #4f46e5; }
.finding-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.finding-head .title { font-weight: 700; font-size: 12.5px; color: #0f172a; }
.finding-head .cid { font-size: 10px; color: #94a3b8; font-family: ui-monospace,
SFMono-Regular, Menlo, monospace; }
.finding p { margin: 6px 0 0; font-size: 12px; color: #334155; }
.finding .rationale { font-size: 11px; color: #64748b; }
.evidence { margin-top: 8px; background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 5px; padding: 8px 10px; font-family: ui-monospace,
SFMono-Regular, Menlo, monospace; font-size: 10px; color: #475569;
white-space: pre-wrap; word-break: break-word; max-height: 260px;
overflow: hidden; }
/* The document is laid out for a page, but it is opened on phones
too. Wide content keeps its own scroller so the page itself never
moves sideways, and the header stacks instead of colliding. */
@media screen and (max-width: 640px) {
.rpt-header { flex-direction: column; align-items: flex-start; gap: 10px; }
.rpt-header-right { text-align: left; }
.attr-tbl { display: block; overflow-x: auto; white-space: nowrap; }
.attr-tbl td, .attr-tbl th { white-space: normal; }
.diagram { padding: 8px; }
.top-bar-subtitle { display: none; }
}
.evidence-block { margin-top: 8px; }
.evidence-block .attr-tbl { font-size: 10.5px; margin: 4px 0 8px; }
.evidence-title { font-size: 11px; font-weight: 700; color: #334155;
margin: 8px 0 2px; }
.evidence-list { margin: 4px 0 8px; padding-left: 18px; font-size: 10.5px;
color: #475569; }
.evidence-list li { margin-bottom: 2px; word-break: break-word; }
.evidence-excerpt-note { margin:7px 0 0 !important; padding-top:6px;
border-top:1px solid #e2e8f0; font-size:10px !important;
color:#64748b !important; }
/* The inherited title is a block; the mark sits on its baseline. */
.section-title { display: flex; align-items: center; }
.sub-title { display: flex; align-items: center; font-size: 12px;
margin: 14px 0 6px; color: #0f172a; }
.muted { color: #64748b; }
.sep { color: #94a3b8; padding: 0 6px; }
.scope { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 14px 16px; font-size: 11.5px; color: #475569; }
.scope ul { margin: 6px 0 0; padding-left: 18px; }
.audit-counters { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; }
.assessment-incomplete { font-weight:600; color:#475569; }
.coverage-panel { border:1px solid #dbeafe; border-radius:8px; padding:14px; margin-bottom:14px; background:#f8fafc; }
.coverage-panel h3 { font-size:13px; margin-bottom:10px; }
.audit-meter { height:9px; background:#e2e8f0; border-radius:5px; overflow:hidden; margin:8px 0; }
.audit-meter > span { display:block; height:100%; background:#3b82f6; }
.coverage-labels { display:flex; justify-content:space-between; gap:15px; font-size:11px; margin-bottom:8px; }
.capacity-item { display:grid; grid-template-columns:1fr 1fr; gap:4px 15px; margin:10px 0; font-size:11px; break-inside:avoid; }
.capacity-item > span { text-align:right; }
.capacity-item .audit-meter { grid-column:1 / -1; }
.technical-ref { font-size:10px !important; }
.technical-entry { margin-bottom:18px; }
.technical-entry > .sub-title { break-after:avoid-page; page-break-after:avoid; }
.audit-table-scroll { max-width:100%; min-width:0; overflow-x:auto; }
.evidence-record { margin:8px 0 16px; }
.evidence-record td:first-child { width:28%; color:#64748b; }
.evidence-record td { overflow-wrap:anywhere; }
.evidence-block .attr-tbl { table-layout:fixed; width:100%; }
.evidence-block .attr-tbl td, .evidence-block .attr-tbl th { overflow-wrap:anywhere; word-break:normal; }
.finding .attr-tbl { font-size:11px; }
.finding .attr-tbl td { overflow-wrap:anywhere; }
.finding .sub-title { break-after:avoid; }
.health-ring .health-icon svg { margin-right:0 !important; }
.audit-verification-ring { position:relative; width:126px; height:126px; flex:0 0 126px; color:#64748b; }
.audit-verification-ring > svg { display:block; width:100%; height:100%; }
.audit-verification-value { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; color:inherit; }
.audit-verification-value strong { font-size:25px; line-height:1.3; }
.audit-verification-value span { font-size:11px; max-width:100px; overflow-wrap:anywhere; }
.audit-result-heading { display:flex; align-items:center; gap:8px; }
.audit-result-heading svg { flex-shrink:0; }
a { color:#2563eb; text-decoration:none; }
@media print {
.audit-table-scroll { overflow:visible; }
.audit-verification-ring, .exec-text p.muted { color:#374151; }
.section, .finding { break-inside:auto; page-break-inside:auto; }
.finding-short { break-inside:avoid-page; page-break-inside:avoid; }
.section-title, .sub-title, .finding-head { break-after:avoid-page; page-break-after:avoid; }
.finding-head + p { break-after:avoid-page; }
.attr-tbl { overflow:visible !important; }
.attr-tbl thead { display:table-header-group; }
.attr-tbl tr { break-inside:avoid-page; page-break-inside:avoid; }
.technical-entry p, .finding p { orphans:3; widows:3; }
.audit-counters, .coverage-panel { break-inside:avoid; }
.diagram { break-inside: avoid; page-break-inside: avoid; }
.chip, .finding { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.evidence { max-height: none; }
}
`
/** Report identifiers follow the family format: prefix and a base-36 stamp. */
export function reportId(prefix: string): string {
return `${prefix}-${Date.now().toString(36).toUpperCase()}`
}
export function esc(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
/** Icon-only actions, as in the rest of the family: the browser's print
* dialog exposes "Save as PDF" as a destination, so one button covers both. */
const PRINT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`
export interface ShellOptions {
title: string
subtitle: string
/** Right-hand header rows, rendered in order. */
meta: Array<[string, string]>
reportId: string
logoUrl: string
topBarSubtitle?: string
footerLeft: string
footerRight: string
lang: string
body: string
/** Extra stylesheet appended after the shared one. */
extraCss?: string
}
export function renderReport(o: ShellOptions): string {
const metaRows = o.meta
.filter(([, v]) => v)
.map(([k, v]) => `<div>${esc(k)}: ${esc(v)}</div>`).join("\n")
return `<!doctype html>
<html lang="${esc(o.lang)}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(o.title)}${o.topBarSubtitle ? ` - ${esc(o.topBarSubtitle)}` : ""}</title>
<style>${REPORT_CSS}${o.extraCss || ""}</style>
</head>
<body>
<script>
function pmxPrint(){ try { window.print(); } catch(e) {} }
</script>
<div class="top-bar no-print">
<div class="top-bar-left">
<strong class="top-bar-title">${esc(o.title)}</strong>
<span class="top-bar-subtitle">${esc(o.topBarSubtitle || "")}</span>
</div>
<div class="btn-group">
<button onclick="pmxPrint()" title="Save as PDF" aria-label="Save as PDF">${PRINT_ICON}</button>
</div>
</div>
<div class="rpt-header">
<div class="rpt-header-left">
<img src="${esc(o.logoUrl)}" alt="ProxMenux" onerror="this.style.display='none'">
<div>
<h1>${esc(o.title)}</h1>
<p>${esc(o.subtitle)}</p>
</div>
</div>
<div class="rpt-header-right">
${metaRows}
<div class="rid">ID: ${esc(o.reportId)}</div>
</div>
</div>
${o.body}
<div class="rpt-footer">
<span>${esc(o.footerLeft)}</span>
<span>${esc(o.footerRight)}</span>
</div>
</body>
</html>`
}
/**
* Section marks.
*
* A document of twelve sections is navigated by flicking through it, and
* a shape is found faster than a word is read. Drawn in the title's own
* grey at a single stroke weight so they mark the section without
* competing with the states, which are the only colour that carries
* meaning here.
*/
const ICON_PATHS: Record<string, string> = {
summary: '<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="m9 14 2 2 4-4"/>',
node: '<rect x="2" y="4" width="20" height="7" rx="2"/><rect x="2" y="13" width="20" height="7" rx="2"/><path d="M6 8h.01M6 17h.01"/>',
cluster: '<circle cx="12" cy="5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/><path d="M12 7.5v4M12 11.5H6.5a1.5 1.5 0 0 0-1.5 1.5v3.5M12 11.5h5.5a1.5 1.5 0 0 1 1.5 1.5v3.5"/>',
architecture: '<rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M10 2v3M14 2v3M10 19v3M14 19v3M2 10h3M2 14h3M19 10h3M19 14h3"/>',
disks: '<rect x="2" y="4" width="20" height="7" rx="2"/><rect x="2" y="13" width="20" height="7" rx="2"/><path d="M17 7.5h.01M17 16.5h.01"/>',
network: '<rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2h14v2"/>',
storage: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
guests: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
passthrough: '<path d="M9 2v6M15 2v6"/><path d="M6 8h12v3a6 6 0 0 1-6 6 6 6 0 0 1-6-6V8Z"/><path d="M12 17v5"/>',
software: '<path d="m12 2 8 4.5v9L12 20l-8-4.5v-9L12 2Z"/><path d="M12 20v-9M4 6.5l8 4.5 8-4.5"/>',
findings: '<path d="m3 6 2 2 3-3M3 13l2 2 3-3M3 20l2 2 3-3"/><path d="M12 7h9M12 14h9M12 21h9"/>',
scope: '<circle cx="12" cy="12" r="9.5"/><path d="M12 16v-5M12 8h.01"/>',
memory: '<rect x="3" y="7" width="18" height="10" rx="1.5"/><path d="M7 17v3M12 17v3M17 17v3M6 11h2M11 11h2M16 11h2"/>',
controller: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>',
adapter: '<rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 12h.01M10 12h.01M14 12h.01"/><path d="M18 8V5M18 19v-3"/>',
bridge: '<path d="M2 17V9a10 10 0 0 1 20 0v8"/><path d="M2 13h20M7 13v4M12 13v4M17 13v4"/>',
observation: '<path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/>',
latency: '<path d="M3 18h4l3-11 4 16 3-9h4"/>',
}
/** An inline mark, sized to sit on the line of the text it precedes. */
export function icon(name: keyof typeof ICON_PATHS | string, size = 16,
color = "#64748b"): string {
const path = ICON_PATHS[name]
if (!path) return ""
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none"
stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" focusable="false"
style="flex:none;vertical-align:-2px;margin-right:8px">${path}</svg>`
}
export function section(index: number, title: string, body: string,
mark?: string): string {
return `<div class="section">
<div class="section-title">${mark ? icon(mark) : ""}${index}. ${esc(title)}</div>
${body}
</div>`
}
/** A heading inside a section, carrying its own mark. */
export function heading(title: string, mark?: string, note?: string): string {
return `<h3 class="sub-title">${mark ? icon(mark, 14) : ""}${esc(title)}${
note ? `<span class="muted" style="font-weight:400"> — ${esc(note)}</span>` : ""}</h3>`
}
export function card(label: string, value: string, opts: { center?: boolean; color?: string } = {}): string {
const cls = opts.center ? "card card-c" : "card"
const style = opts.color ? ` style="color:${opts.color}"` : ""
return `<div class="${cls}">
<div class="card-label">${esc(label)}</div>
<div class="card-value"${style}>${value}</div>
</div>`
}
export function grid(columns: 2 | 3 | 4, cards: string[]): string {
return `<div class="grid-${columns}">${cards.join("")}</div>`
}
/** Callout in the family's four tones: ok, warn, critical, info. */
export function callout(tone: "ok" | "warn" | "critical" | "info",
title: string, body: string): string {
const icon = { ok: "&#10003;", warn: "&#9888;", critical: "&#10007;", info: "&#9432;" }[tone]
return `<div class="rec-item rec-${tone}">
<div class="rec-icon">${icon}</div>
<div><strong>${esc(title)}</strong><p>${body}</p></div>
</div>`
}
export function table(headers: string[], rows: string[][]): string {
// No headers means the first column labels the second: a record read
// down rather than across.
const head = headers.length
? `<thead><tr>${headers.map((h) => `<th>${esc(h)}</th>`).join("")}</tr></thead>`
: ""
return `<table class="attr-tbl">${head}
<tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")}</tbody>
</table>`
}
/**
* Opens the report window on the click itself, before any data is
* fetched, so the popup blocker sees the user gesture. The spinner is
* what the reader looks at while the document is composed.
*/
export function openReportWindow(loadingText: string): Window | null {
const w = window.open("about:blank", "_blank")
if (w) {
w.document.write(`<html><body style="background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><div style="border:3px solid transparent;border-top-color:#06b6d4;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:0 auto"></div><p style="margin-top:16px">${esc(loadingText)}</p><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div></body></html>`)
}
return w
}
/**
* Hands the composed document to the window that was opened on the click.
*
* The window is *navigated* to the document rather than written into.
* Writing into an about:blank window leaves it, as far as the browser is
* concerned, still on about:blank — no navigation happened — and an
* installed web app then shows none of its own chrome, so on a phone the
* report opens with no way back to the page that launched it. Navigating
* to a blob URL is a real navigation, and the app supplies its close and
* back controls exactly as it does for the other reports.
*/
export function writeReport(target: Window | null, html: string): void {
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }))
if (target && !target.closed) {
target.location.href = url
return
}
// The window was blocked or the reader closed it while the document
// was being composed.
window.open(url, "_blank")
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+420 -47
View File
@@ -18,6 +18,10 @@ not acceptable.
from __future__ import annotations
import os
import json
import socket
import sys
import copy
import re
import subprocess
import time
@@ -44,22 +48,30 @@ AREAS = (
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
# Shared deadline for all subprocesses in a check, not a fresh timeout
# per device/storage. Exhaustion is unknown, never not applicable.
CHECK_TIMEOUT = 30
RUN_TIMEOUT = 300
CATALOG_VERSION = 14
# A check that has to produce its own evidence — rather than read
# evidence something else already produced — declares how long that
# takes. The budget is still bounded by the run's own deadline.
LYNIS_RUN_BUDGET = 240
class Check:
"""One registered assessment.
``evaluate`` receives the context and returns a dict with ``state``
``evaluate`` receives the context and returns a dict with ``classification``
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]]):
evaluate: Callable[["AuditContext"], Optional[dict]],
budget: int = CHECK_TIMEOUT):
if area not in AREAS:
raise ValueError(f"unknown area for {check_id}: {area}")
if severity not in SEVERITIES:
@@ -70,17 +82,20 @@ class Check:
self.area = area
self.severity = severity
self.evaluate = evaluate
self.budget = budget
self.version = CATALOG_VERSION
_REGISTRY: dict[str, Check] = {}
def register(check_id: str, area: str, severity: str):
def register(check_id: str, area: str, severity: str,
budget: int = CHECK_TIMEOUT):
"""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)
_REGISTRY[check_id] = Check(check_id, area, severity, fn, budget)
return fn
return wrap
@@ -98,26 +113,91 @@ class AuditContext:
def __init__(self):
self._cache: dict[str, Any] = {}
self._source_info = {}
self._dependencies = {}
self._sources_used = set()
self._errors = {}
self._check_deadline = float("inf")
self._run_deadline = time.monotonic() + RUN_TIMEOUT
def begin_check(self, budget: int = CHECK_TIMEOUT):
self._sources_used = set()
self._check_deadline = min(time.monotonic() + budget, self._run_deadline)
def source(self, key, *, error=None):
self._sources_used.add(key)
self._source_info.setdefault(key, {"source": key, "collected_at": int(time.time())})
if error:
self._errors[key] = str(error)
if key in self._errors:
self._source_info[key]["error"] = self._errors[key]
def read(self, path, *, optional=False):
def load():
try:
return Path(path).read_text(errors="replace")
except FileNotFoundError:
if optional:
return ""
raise
return self._once(str(path), load) or ""
@property
def node(self):
return socket.gethostname().split(".")[0]
@property
def policy(self):
"""What has been declared about this host, or nothing declared.
Read once per assessment so every check judges against the same
declaration, even if the file changes while a run is in progress.
"""
def load():
import audit_policy
value = audit_policy.load()
if value.error:
self.source("policy", error=value.error)
return value
return self._once("policy", load)
def _once(self, key: str, producer: Callable[[], Any]) -> Any:
self.source(key)
if key not in self._cache:
parent_sources = self._sources_used
self._sources_used = {key}
try:
self._cache[key] = producer()
except Exception:
except Exception as exc:
self._cache[key] = None
self.source(key, error=exc)
finally:
self._dependencies[key] = self._sources_used - {key}
parent_sources.update(self._sources_used)
self._sources_used = parent_sources
else:
for dependency in self._dependencies.get(key, ()):
self.source(dependency)
return self._cache[key]
def run(self, cmd: list[str], timeout: int = 10) -> tuple[int, str]:
def run(self, cmd: list[str], timeout: int = 10, allowed_codes=(0,)) -> tuple[int, str]:
"""Run a read-only command, returning exit code and output."""
key = f"cmd:{' '.join(cmd)}"
key = "cmd:" + json.dumps(cmd)
self.source(key)
if key in self._cache:
return self._cache[key]
try:
remaining = min(timeout, self._check_deadline - time.monotonic(),
self._run_deadline - time.monotonic())
if remaining <= 0:
raise TimeoutError("assessment time budget exhausted")
proc = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout)
timeout=remaining, env={**os.environ, "LC_ALL": "C", "LANG": "C"})
result = (proc.returncode, (proc.stdout or "") + (proc.stderr or ""))
except Exception as exc:
result = (-1, str(exc))
if result[0] not in allowed_codes:
self.source(key, error=f"exit {result[0]}: {result[1][:500]}")
self._cache[key] = result
return result
@@ -128,11 +208,13 @@ class AuditContext:
out: dict[int, str] = {}
base = Path("/etc/pve/lxc")
if not base.is_dir():
self.source("lxc_configs", error="local PVE configuration directory unavailable")
return out
for path in base.glob("*.conf"):
try:
out[int(path.stem)] = path.read_text(errors="replace")
except (OSError, ValueError):
except (OSError, ValueError) as exc:
self.source("lxc_configs", error=f"{path}: {exc}")
continue
return out
return self._once("lxc_configs", load) or {}
@@ -143,15 +225,31 @@ class AuditContext:
out: dict[int, str] = {}
base = Path("/etc/pve/qemu-server")
if not base.is_dir():
self.source("qemu_configs", error="local PVE configuration directory unavailable")
return out
for path in base.glob("*.conf"):
try:
out[int(path.stem)] = path.read_text(errors="replace")
except (OSError, ValueError):
except (OSError, ValueError) as exc:
self.source("qemu_configs", error=f"{path}: {exc}")
continue
return out
return self._once("qemu_configs", load) or {}
@property
def cluster_configs(self):
"""Local pmxcfs view only, to protect volumes referenced by other nodes."""
def load():
result = {}
base = Path("/etc/pve/nodes")
if not base.is_dir():
raise OSError("cluster configuration view unavailable")
for kind in ("lxc", "qemu-server"):
for path in base.glob(f"*/{kind}/*.conf"):
result[str(path)] = path.read_text(errors="replace")
return result
return self._once("cluster_configs", load) or {}
@property
def apt_sources(self) -> dict[str, str]:
"""Contents of the apt source files that define PVE repositories."""
@@ -165,8 +263,10 @@ class AuditContext:
for path in candidates:
try:
out[str(path)] = path.read_text(errors="replace")
except OSError:
except FileNotFoundError:
continue
except OSError as exc:
self.source("apt_sources", error=f"{path}: {exc}")
return out
return self._once("apt_sources", load) or {}
@@ -178,11 +278,86 @@ class AuditContext:
for path in (Path("/etc/pve/jobs.cfg"), Path("/etc/vzdump.cron")):
try:
text += path.read_text(errors="replace") + "\n"
except OSError:
except FileNotFoundError:
continue
except OSError as exc:
self.source("vzdump_jobs", error=f"{path}: {exc}")
return text
return self._once("vzdump_jobs", load) or ""
def _run_lynis(self):
"""Produce a Lynis report.
Returns the parsed report, whether this assessment produced it,
and why it could not, so a check reports what actually happened
rather than asserting a run that may never have started.
"""
from security_manager import (_find_lynis_cmd, get_lynis_audit_status,
parse_lynis_report, run_lynis_audit)
if not _find_lynis_cmd():
return None, False, None
deadline = min(time.monotonic() + LYNIS_RUN_BUDGET, self._run_deadline)
if not get_lynis_audit_status().get("running"):
started, message = run_lynis_audit()
if not started and "already running" not in (message or "").lower():
reason = message or "Lynis could not be started"
self.source("lynis:run", error=reason)
return None, False, reason
# A quick audit takes about a minute; the wait is bounded by the
# budget and by the assessment's own deadline.
while get_lynis_audit_status().get("running"):
if time.monotonic() >= deadline:
reason = "Lynis was still running when the time budget ran out"
self.source("lynis:run", error=reason)
return None, True, reason
time.sleep(2)
self.source("lynis:run")
return parse_lynis_report(enrich_current=False), True, None
@property
def lynis_report(self) -> Optional[dict]:
"""The most recent Lynis audit, running one if there is none.
An assessment that reports "not verified" because nobody has
opened the Security page yet is reporting on the Monitor, not on
the host. Where Lynis is installed and has no usable report or
only the remains of an interrupted run the audit is produced
here, because that reading is what was asked for. Where Lynis is
not installed there is nothing to report and the checks do not
apply.
The run goes through Security's own entry point, which holds the
lock that keeps two audits from starting at once, so an audit the
user launched from that page is waited on rather than duplicated.
"""
def load():
from security_manager import parse_lynis_report
parsed = parse_lynis_report(enrich_current=False)
ran, run_error = False, None
if parsed is None or not parsed.get("is_complete"):
produced, ran, run_error = self._run_lynis()
if produced is not None:
parsed = produced
if parsed is None:
return None
source = next((p for p in (Path("/var/log/lynis-report.dat"),
Path("/var/log/lynis-output.log")) if p.exists()), None)
return {
"mtime": source.stat().st_mtime if source else 0,
"source": str(source), "version": parsed.get("lynis_version"),
"warnings": parsed.get("warnings", []),
"suggestions": parsed.get("suggestions", []),
"hardening_index": parsed.get("hardening_index"),
"complete": parsed.get("is_complete", False),
# What the assessment itself did, so a check can say
# whether it is reporting a stored result or one it
# produced, and why a produced one is unusable.
"produced_here": ran,
"run_error": run_error,
}
return self._once("lynis_report", load)
@property
def storages(self) -> list[dict]:
"""Storage definitions from ``storage.cfg``.
@@ -197,7 +372,7 @@ class AuditContext:
try:
text = Path("/etc/pve/storage.cfg").read_text(errors="replace")
except OSError:
return out
raise
current: Optional[dict] = None
for line in text.splitlines():
if not line.strip():
@@ -221,77 +396,267 @@ class AuditContext:
def load():
try:
return Path("/etc/pve/user.cfg").read_text(errors="replace")
except OSError:
except FileNotFoundError:
return ""
return self._once("pve_user_cfg", load) or ""
@property
def storage_snapshot(self):
"""Reuse recent Monitor storage observations; one PVE metadata read otherwise.
Never invoke a mount, activate a volume, or connect to a remote host.
A successful PVE resource query is not an end-to-end storage IO test.
"""
def load():
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
cache = copy.deepcopy(getattr(server, "_proxmox_storage_cache", {}))
when = cache.get("time", 0)
data = cache.get("data")
if (isinstance(data, dict) and isinstance(data.get("storage"), list)
and "error" not in data and 0 <= time.time() - when <= 120):
return {"rows": data["storage"], "collected_at": when,
"source": "Monitor storage cache", "units": "GiB"}
rc, out = self.run(["pvesh", "get", "/cluster/resources", "--type", "storage",
"--output-format", "json"], timeout=10)
if rc != 0:
raise RuntimeError("PVE storage resource metadata unavailable")
resources = json.loads(out)
if not isinstance(resources, list) or any(not isinstance(r, dict) for r in resources):
raise ValueError("unrecognised storage resource metadata")
rows = [{"name": r.get("storage"), "node": r.get("node"),
"status": r.get("status", "unknown"), "total": r.get("maxdisk"),
"used": r.get("disk"), "type": r.get("plugintype")}
for r in resources if r.get("node") == self.node]
return {"rows": rows, "collected_at": time.time(),
"source": "PVE cluster resource metadata", "units": "bytes"}
return self._once("storage_snapshot", load) or {}
def _block_devices(self) -> list[str]:
"""Real disks, as the kernel lists them."""
# zd* are ZFS volumes and dm-* device-mapper targets: guest
# storage rather than hardware, with no SMART to read.
skip = ("loop", "ram", "zram", "dm-", "md", "sr", "nbd", "fd", "zd")
try:
return sorted(d.name for d in Path("/sys/block").iterdir()
if not d.name.startswith(skip))
except OSError:
return []
@property
def monitor_snapshot(self):
"""Copy existing Monitor data without triggering probes or importing Flask."""
def load():
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
smart = copy.deepcopy(getattr(server, "_smart_result_cache", {}))
# That cache is filled by whoever last opened the storage view,
# so an assessment can find it empty and report nothing about
# disks the interface is already showing wear for. Ask through
# the Monitor's own accessor for what is missing: it serves a
# sleeping disk from its last known values rather than waking
# it, and reuses the same 30 s memoisation the interface hits.
reader = getattr(server, "get_smart_data", None)
if callable(reader):
for device in self._block_devices():
if device in smart:
continue
if time.monotonic() >= self._run_deadline:
break
try:
data = reader(device)
except Exception:
continue
if isinstance(data, dict):
smart[device] = (time.time(), data)
health_module = sys.modules.get("health_monitor")
monitor = getattr(health_module, "health_monitor", None)
health = copy.deepcopy(getattr(monitor, "cached_results", {}).get("_bg_detailed"))
when = getattr(monitor, "last_check_times", {}).get("_bg_detailed")
return {"smart": smart, "health": health, "health_collected_at": when}
return self._once("monitor_snapshot", load) or {}
def metadata(self, checks):
def local(path):
try:
return Path(path).read_text().strip()
except OSError:
return None
version = (local(Path(__file__).resolve().parents[1] / "package.json") or
local(Path(__file__).resolve().parents[2] / "package.json"))
try:
version = json.loads(version or "{}").get("version")
except ValueError:
version = None
rc, pve = self.run(["pveversion"], timeout=5)
return {"host": self.node, "kernel": os.uname().release,
"boot_id": local("/proc/sys/kernel/random/boot_id"),
"proxmenux_version": version, "pve_version": pve.strip() if rc == 0 else None,
"catalog_version": CATALOG_VERSION, "scope": "local node; no guest interior probes",
"checks": [c.check_id for c in checks],
"policy": self.policy.describe(),
"health_snapshot": self.monitor_snapshot.get("health"),
"health_collected_at": self.monitor_snapshot.get("health_collected_at")}
# ---------------------------------------------------------------------------
# Evaluation
# ---------------------------------------------------------------------------
def _classification_of(result: dict, check: "Check") -> str:
"""The gravity of a result, from the result itself.
A check states the gravity of what it found. Where several objects
were examined and each carries its own, the finding takes the gravest
of them, because a report that says "observation" over an object it
marked critical is wrong about the object it matters most for.
"""
per_object = [o.get("classification") for o in (result.get("affected") or [])
if isinstance(o, dict) and o.get("classification")]
declared = result.get("classification")
values = ([declared] if declared else []) + per_object
if result.get("incomplete"):
values.append(audit_store.CLASS_UNVERIFIED)
if any(v not in audit_store.CLASSIFICATIONS for v in values):
values.append(audit_store.CLASS_UNVERIFIED)
problems = [v for v in values if v in audit_store.CLASS_PROBLEMS]
if problems:
return audit_store.worst(problems)
if audit_store.CLASS_UNVERIFIED in values:
return audit_store.CLASS_UNVERIFIED
if values:
return audit_store.worst(values)
if declared in audit_store.CLASSIFICATIONS:
return declared
# A check that has not been migrated to the scale is read on it from
# what it used to return, so the catalogue keeps working while the
# rules are revised one by one.
return audit_store.classification_of(
result.get("state", audit_store.STATE_UNKNOWN), check.severity)
def run_assessment(profile: str = "full",
only_areas: Optional[set[str]] = None) -> str:
only_areas: Optional[set[str]] = None, *, run_id=None, progress=None) -> str:
"""Evaluate every registered check and persist the result.
A check that raises is recorded as not applicable with the error kept
A check that raises is recorded as unverified 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.
"""
import audit_profiles
if not audit_profiles.is_known(profile) or (
only_areas is not None and (not only_areas or not only_areas <= set(AREAS))):
raise ValueError("unsupported audit profile or areas")
# The profile narrows the catalogue to its question; an explicit area
# filter narrows it further within that.
checks = audit_profiles.selected_checks(profile, registered_checks())
if only_areas is not None:
checks = [c for c in checks if c.area in only_areas]
ctx = AuditContext()
exceptions = audit_store.active_exceptions()
run_id = audit_store.start_run(profile)
metadata = ctx.metadata(checks)
if run_id is None:
run_id = audit_store.start_run(profile, metadata, len(checks))
else:
audit_store.update_run_metadata(run_id, metadata, len(checks))
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
for check in checks:
ctx.begin_check(check.budget)
if progress:
progress(run_id, len(findings), len(checks), check.check_id)
started = time.monotonic()
try:
if started >= ctx._run_deadline:
raise TimeoutError("assessment time budget exhausted")
result = check.evaluate(ctx)
if result is not None and (not isinstance(result, dict)
or not isinstance(result.get("affected", []), list)
or any(not isinstance(obj, dict) for obj in result.get("affected", []))):
raise ValueError("invalid check result")
except Exception as exc:
result = {
"state": audit_store.STATE_NOT_APPLICABLE,
"classification": audit_store.CLASS_UNVERIFIED,
"summary_key": "evaluationFailed",
"evidence": f"{type(exc).__name__}: {exc}",
}
elapsed = time.monotonic() - started
if result is None:
result = {"state": audit_store.STATE_NOT_APPLICABLE}
# No prose here: this sentence reached a report that
# exists in eight languages. The interface says it in the
# reader's own, and a check with something specific to
# say returns its own summary instead of None.
result = {"classification": audit_store.CLASS_NOT_APPLICABLE}
state = result.get("state", audit_store.STATE_NOT_APPLICABLE)
errors = [f"{k}: {ctx._errors[k]}" for k in ctx._sources_used if k in ctx._errors]
if elapsed > check.budget:
errors.append("check time budget exceeded")
if errors:
result["incomplete"] = True
result["evidence"] = (result.get("evidence") or "") + "\n" + "\n".join(errors)
# A source that could not be read cannot turn into a clean
# result, but it must not soften one that already found a
# problem either: what was found stands, what was missed is
# named.
if _classification_of(result, check) not in audit_store.CLASS_PROBLEMS:
result.update(classification=audit_store.CLASS_UNVERIFIED,
summary_key="evaluationFailed")
classification = _classification_of(result, check)
# 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({
# Names already collected by a check are display metadata, not a
# reason to probe guests again or alter the finding's scope.
for obj in result.get("affected") or []:
vmid = obj.get("vmid")
if vmid is None or obj.get("name"):
continue
for cache_key, field in (("lxc_configs", "hostname"), ("qemu_configs", "name")):
config = (getattr(ctx, "_cache", {}).get(cache_key) or {}).get(vmid, "")
match = re.search(r"^" + field + r":\s*(.+)$", config, re.MULTILINE)
if match:
obj["name"] = match.group(1).strip()
break
finding = {
"check_id": check.check_id,
"area": check.area,
# Retained as the gravity the check can reach at worst,
# which is what the catalogue advertises; the finding's own
# gravity is its classification.
"severity": check.severity,
"state": state,
"classification": classification,
"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"),
})
"raw_classification": classification,
"check_version": check.version, "host": ctx.node,
"collected_at": int(time.time()), "incomplete": result.get("incomplete", False),
"observations": result.get("observations", []),
"sources": [ctx._source_info[k] for k in sorted(ctx._sources_used)],
}
finding["scope"] = audit_store.finding_scope(finding)
decision = exceptions.get(check.check_id)
if (classification in audit_store.CLASS_PROBLEMS and decision
and decision.get("scope") == finding["scope"] and not finding["incomplete"]
and (decision.get("expires_at") is None or decision["expires_at"] > time.time())):
finding.update(decision=audit_store.DECISION_ACCEPTED, exception=decision)
findings.append(finding)
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)
audit_store.finish_run(
run_id, checks_total=len(findings), error=error,
partial=any(f["classification"] == audit_store.CLASS_UNVERIFIED
or f.get("incomplete") for f in findings))
if progress:
progress(run_id, len(findings), len(checks), None)
return run_id
@@ -307,30 +672,37 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
``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}
problems = set(audit_store.CLASS_PROBLEMS)
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 = [], [], [], []
new, resolved, accepted, unchanged, unverified = [], [], [], [], []
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:
was = previous["classification"] in problems if previous else False
now = current["classification"] in problems
if current["classification"] in (audit_store.CLASS_UNVERIFIED,
audit_store.CLASS_NOT_APPLICABLE) \
or current.get("incomplete"):
unverified.append(current)
elif now and current.get("decision") == audit_store.DECISION_ACCEPTED:
accepted.append(current)
elif now and (not was or previous["classification"] != current["classification"]):
new.append(current)
elif was and not now:
if current["state"] == audit_store.STATE_ACCEPTED:
if current.get("decision") == audit_store.DECISION_ACCEPTED:
accepted.append(current)
else:
elif current["classification"] in (audit_store.CLASS_CONFORMANT,
audit_store.CLASS_OBSERVATION):
resolved.append(current)
elif previous and previous["state"] == current["state"]:
elif previous and previous["classification"] == current["classification"]:
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
if check_id not in other and previous["classification"] in problems
]
return {
@@ -339,4 +711,5 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
"accepted": accepted,
"unchanged": unchanged,
"retired": retired,
"unverified": unverified,
}
File diff suppressed because it is too large Load Diff
+844
View File
@@ -0,0 +1,844 @@
"""Structural inventory for Audit & Report.
Composes what the node is, what it holds and how those pieces connect,
from the collectors the Monitor already runs. Nothing here probes the
host: every section reads material that exists for another purpose.
The value of an inventory is not the lists but the relations between
them. Enumerating interfaces and enumerating guests does not say which
path a guest's traffic takes to the wire, nor which device a virtual
disk actually lives on. Those chains are resolved here:
guest -> disk -> storage -> backing device
guest -> interface -> bridge -> bond -> physical NIC
guest -> backup job -> destination
guest -> passthrough device -> IOMMU group -> controller
node -> uplink -> measured latency to gateway and to the internet
Sections degrade independently. A source that cannot be read leaves its
section marked unavailable with the reason, rather than dropping the
whole inventory or presenting a gap as an empty result.
"""
from __future__ import annotations
import copy
import re
import sys
import time
from typing import Any, Optional
SCHEMA_VERSION = 2
# Disk entries in a guest configuration: rootfs and mpN for containers,
# the bus-prefixed keys for virtual machines.
_DISK_KEYS = re.compile(
r"^(rootfs|mp\d+|scsi\d+|virtio\d+|sata\d+|ide\d+|efidisk\d+|tpmstate\d+):",
re.M)
def _kv(text: str, key: str) -> str:
m = re.search(rf"^{key}:\s*(.+)$", text, re.M)
return m.group(1).strip() if m else ""
def _parse_options(value: str) -> dict[str, str]:
"""Split a Proxmox option string into its comma-separated pairs."""
out: dict[str, str] = {}
for part in value.split(","):
if "=" in part:
k, v = part.split("=", 1)
out[k.strip()] = v.strip()
return out
def _guest_disks(text: str) -> list[dict[str, Any]]:
"""Disks declared by a guest, resolved to their storage.
A volume reads as ``storage:volume,option=value``. Anything without
that shape is a passthrough or a raw device path and is reported as
such rather than being attributed to a storage that does not own it.
"""
disks = []
for line in text.splitlines():
m = _DISK_KEYS.match(line)
if not m:
continue
key = m.group(1)
value = line.split(":", 1)[1].strip()
head = value.split(",", 1)[0]
options = _parse_options(value)
entry: dict[str, Any] = {"slot": key, "size": options.get("size", "")}
if ":" in head and not head.startswith("/"):
storage, volume = head.split(":", 1)
entry.update(storage=storage, volume=volume)
else:
entry.update(storage=None, volume=head, passthrough=True)
disks.append(entry)
return disks
def _guest_interfaces(text: str) -> list[dict[str, Any]]:
"""Network devices declared by a guest, with the bridge each uses."""
out = []
for line in text.splitlines():
m = re.match(r"^(net\d+):\s*(.+)$", line)
if not m:
continue
options = _parse_options(m.group(2))
out.append({
"slot": m.group(1),
"name": options.get("name", ""),
"bridge": options.get("bridge", ""),
"mac": options.get("hwaddr") or options.get("macaddr", ""),
"vlan": options.get("tag", ""),
"model": next((p for p in m.group(2).split(",") if "=" not in p), ""),
})
return out
def _network_topology() -> Optional[dict[str, Any]]:
"""Physical path from each bridge to the wire.
Built from the Monitor's own per-interface resolvers rather than from
the aggregate network payload: ``get_bridge_info`` already reports a
bridge's uplink and, when that uplink is a bond, its member
interfaces. Absent those resolvers the chain is left unresolved
rather than guessed.
"""
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
bridge_info = getattr(server, "get_bridge_info", None)
bond_info = getattr(server, "get_bond_info", None)
if not callable(bridge_info):
return None
try:
from pathlib import Path
# fwbr* bridges are created by Proxmox per guest interface to
# attach its firewall. They are plumbing rather than part of the
# host's configured topology, so the inventory omits them.
names = sorted(p.name for p in Path("/sys/class/net").iterdir()
if (p / "bridge").is_dir()
and not p.name.startswith("fwbr"))
except OSError:
return None
bridges: dict[str, Any] = {}
bonds: dict[str, Any] = {}
for name in names:
try:
info = copy.deepcopy(bridge_info(name))
except Exception:
continue
if not isinstance(info, dict):
continue
uplink = info.get("physical_interface")
vlan = info.get("vlan_interface")
chain: list[dict[str, str]] = []
if uplink:
slaves = info.get("bond_slaves") or []
if slaves:
mode = ""
if callable(bond_info):
try:
detail = bond_info(uplink) or {}
mode = detail.get("mode_detail") or detail.get("mode", "")
bonds[uplink] = detail
except Exception:
mode = ""
chain.append({"kind": "bond", "id": uplink, "mode": mode})
chain.extend({"kind": "nic", "id": s} for s in slaves)
else:
chain.append({"kind": "nic", "id": uplink})
bridges[name] = {
"parent": uplink,
"vlan_interface": vlan,
# Guest taps are excluded upstream, so members here are the
# bridge's own ports rather than every attached guest.
"members": info.get("members") or [],
"uplink": chain,
}
return {"bridges": bridges, "bonds": bonds}
def _latency(ctx) -> Optional[dict[str, Any]]:
"""Network latency over the last day, from the Monitor's own history.
The Monitor samples the gateway and two public resolvers
continuously. A report that describes a node's network without
saying how it behaves is describing the wiring, not the network, so
the measurements already on disk are carried here. Nothing is probed:
the samples exist whether or not anyone asks for them.
"""
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
history = getattr(server, "get_latency_history", None)
if not callable(history):
return None
targets = []
for name in ("gateway", "cloudflare", "google"):
try:
result = history(name, "day") or {}
except Exception:
continue
stats = result.get("stats") or {}
samples = result.get("data") or []
if not samples:
continue
losses = [s.get("packet_loss") for s in samples
if isinstance(s.get("packet_loss"), (int, float))]
targets.append({
"target": name,
"samples": len(samples),
"min_ms": stats.get("min"),
"avg_ms": stats.get("avg"),
"max_ms": stats.get("max"),
"current_ms": stats.get("current"),
"packet_loss": round(sum(losses) / len(losses), 2) if losses else None,
# Kept for the chart: one point per sample, oldest first.
# The peak travels with the average because a chart of
# averages alone contradicts the maximum in the table.
"series": [{"t": s.get("timestamp"), "v": s.get("value"),
"max": s.get("max")}
for s in samples if s.get("value") is not None],
})
if not targets:
return None
return {"window": "day", "targets": targets}
def _backup_map(ctx) -> dict[int, list[dict[str, str]]]:
"""Which enabled backup job selects each guest, and where it writes."""
import audit_checks_pve as pve
guests = set(ctx.lxc_configs) | set(ctx.qemu_configs)
pools = pve._pool_members(ctx.pve_user_cfg)
out: dict[int, list[dict[str, str]]] = {}
for job in pve._parse_vzdump_jobs(ctx.vzdump_jobs):
if job.get("enabled", "1").strip() == "0":
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())
entry = {"job": job["id"], "storage": job.get("storage", ""),
"schedule": job.get("schedule", ""),
"retention": job.get("prune-backups") or job.get("maxfiles", "")}
for vmid in selected - excluded:
out.setdefault(vmid, []).append(entry)
return out
def _identity(ctx) -> dict[str, Any]:
rc, version = ctx.run(["pveversion"], timeout=10)
rc2, kernel = ctx.run(["uname", "-r"], timeout=10)
rc3, sub = ctx.run(["pvesubscription", "get"], timeout=10)
status = ""
for line in (sub or "").splitlines():
if line.lower().startswith("status:"):
status = line.split(":", 1)[1].strip()
break
cluster = ""
try:
from pathlib import Path
corosync = Path("/etc/corosync/corosync.conf")
if corosync.exists():
m = re.search(r"cluster_name:\s*(\S+)",
corosync.read_text(errors="replace"))
cluster = m.group(1) if m else "unnamed"
except OSError:
cluster = ""
return {
"node": ctx.node,
"pve_version": (version or "").strip().splitlines()[0] if version else "",
"kernel": (kernel or "").strip(),
"subscription": status or "unknown",
"cluster": cluster or None,
}
def _storages(ctx) -> list[dict[str, Any]]:
out = []
for storage in ctx.storages:
out.append({
"id": storage.get("id"),
"type": storage.get("type"),
"content": storage.get("content", ""),
"shared": str(storage.get("shared", "0")).strip() == "1",
"path": storage.get("path") or storage.get("export") or "",
"server": storage.get("server", ""),
})
return sorted(out, key=lambda s: s["id"] or "")
def _guests(ctx, topology, backups) -> list[dict[str, Any]]:
"""Every local guest with its disks, interfaces and protection resolved."""
entries = []
for kind, configs in (("lxc", ctx.lxc_configs), ("qemu", ctx.qemu_configs)):
for vmid, text in configs.items():
interfaces = _guest_interfaces(text)
for nic in interfaces:
if topology is None:
# Distinguish a bridge with no uplink from one whose
# path could not be read: the first is a fact about
# the host, the second is a gap in this inventory.
nic["uplink"] = None
else:
bridge = topology["bridges"].get(nic["bridge"])
nic["uplink"] = bridge["uplink"] if bridge else []
entries.append({
"vmid": vmid,
"type": kind,
"name": _kv(text, "hostname") or _kv(text, "name"),
"cores": _kv(text, "cores"),
"memory": _kv(text, "memory"),
"ostype": _kv(text, "ostype"),
"onboot": _kv(text, "onboot") == "1",
"tags": _kv(text, "tags"),
"protected": _kv(text, "protection") == "1",
"unprivileged": _kv(text, "unprivileged") == "1" if kind == "lxc" else None,
"features": _kv(text, "features") if kind == "lxc" else None,
"agent": bool(_kv(text, "agent")) if kind == "qemu" else None,
"cpu": _kv(text, "cpu") if kind == "qemu" else None,
"disks": _guest_disks(text),
"interfaces": interfaces,
"backups": backups.get(vmid, []),
})
return sorted(entries, key=lambda g: g["vmid"])
def collect(ctx, sections: Optional[tuple] = None) -> dict[str, Any]:
"""Assemble the inventory, keeping each section independent.
A section that raises is recorded with its error so the rest of the
document still describes what could be read. An inventory that fails
as a whole because one source was unavailable is less useful than one
that says which part is missing.
"""
out: dict[str, Any] = {}
errors: dict[str, str] = {}
wanted = None if sections is None else set(sections)
def section(name, producer):
# A section the profile did not ask for is absent rather than
# empty, so a reader never takes an omission for a finding.
if wanted is not None and name not in wanted:
return
try:
out[name] = producer()
except Exception as exc:
out[name] = None
errors[name] = f"{type(exc).__name__}: {exc}"
topology = None
try:
topology = _network_topology()
if topology is None:
errors["network"] = ("the Monitor's network view is not reachable "
"from this process, so bridge uplinks are "
"unresolved")
except Exception as exc:
errors["network"] = f"{type(exc).__name__}: {exc}"
backups: dict[int, list] = {}
try:
backups = _backup_map(ctx)
except Exception as exc:
errors["backup_map"] = f"{type(exc).__name__}: {exc}"
section("identity", lambda: _identity(ctx))
section("hardware", lambda: _hardware(ctx))
section("cluster", lambda: _cluster(ctx))
section("storages", lambda: _storages(ctx))
section("guests", lambda: _guests(ctx, topology, backups))
section("passthrough", lambda: _passthrough(ctx))
section("applications", lambda: _applications(ctx))
section("custom_links", _custom_links)
section("proxmenux", lambda: _proxmenux(ctx))
section("latency", lambda: _latency(ctx))
if wanted is None or "network" in wanted:
out["network"] = topology
return {
"schema_version": SCHEMA_VERSION,
"collected_at": int(time.time()),
"node": ctx.node,
"sections": out,
# Named so a reader can tell an empty section from an unread one.
"unavailable": errors,
}
# ---------------------------------------------------------------------------
# Passthrough, applications and hardware
# ---------------------------------------------------------------------------
def _iommu_groups() -> dict[str, str]:
"""Map each PCI address to the IOMMU group that contains it.
A device can only be handed to a guest together with everything else
in its group, so the group is what determines whether a passthrough
is possible at all.
"""
from pathlib import Path
out: dict[str, str] = {}
base = Path("/sys/kernel/iommu_groups")
if not base.is_dir():
return out
for group in base.iterdir():
devices = group / "devices"
if not devices.is_dir():
continue
for device in devices.iterdir():
out[device.name] = group.name
return out
def _passthrough(ctx) -> list[dict[str, Any]]:
"""PCI devices assigned to a guest, with their IOMMU group.
``hostpci`` may name a function (``0000:03:00.0``) or a whole device
(``0000:03:00``). Both are reported as written and resolved against
the groups, so a reader sees what was configured rather than a
normalised form that no longer matches the configuration.
"""
groups = _iommu_groups()
out = []
for vmid, text in sorted(ctx.qemu_configs.items()):
name = _kv(text, "name")
for line in text.splitlines():
m = re.match(r"^(hostpci\d+):\s*(.+)$", line)
if not m:
continue
value = m.group(2)
address = value.split(",", 1)[0].strip()
# A device written without its function covers every function
# of that device, so the group is looked up through them.
candidates = ([address] if address.count(".") else
[f"{address}.{fn}" for fn in range(8)])
found = {groups[c] for c in candidates if c in groups}
out.append({
"vmid": vmid,
"guest": name,
"slot": m.group(1),
"address": address,
"options": _parse_options(value),
"iommu_groups": sorted(found) or None,
"shared_group_devices": sorted(
d for d, gid in groups.items()
if gid in found and d not in candidates) or [],
})
return out
def _applications(ctx) -> list[dict[str, Any]]:
"""Applications registered inside each container and their web links.
Read from the sidecars the App tab maintains, which is where a
container's real purpose is recorded; the configuration alone only
says how much memory it has.
"""
import json as _json
from pathlib import Path
base = Path("/etc/proxmenux/apps")
out = []
if not base.is_dir():
return out
for path in sorted(base.glob("*.json")):
try:
data = _json.loads(path.read_text(errors="replace"))
except (OSError, ValueError):
continue
vmid = data.get("vmid")
for app in data.get("apps", []) or []:
# Detection results live under `state`, separate from the
# registration itself, and carry the moment they were taken.
# A version that could not be detected is stored as null, so
# the value is coerced rather than defaulted: a key present
# with no value would otherwise pass a default straight through.
state = app.get("state") or {}
out.append({
"vmid": vmid,
"name": app.get("name") or "",
"slug": app.get("helper_slug") or app.get("slug") or "",
"installed_via": app.get("installed_via") or "",
"version": state.get("installed_version") or "",
"available": state.get("latest_version") or "",
"update_available": bool(state.get("update_available")),
"checked_at": state.get("checked_at") or "",
"ports": [
{"port": p.get("port"), "path": p.get("web_path", ""),
"scheme": p.get("scheme", ""),
"category": p.get("category", ""),
"url": p.get("custom_url", "")}
for p in (app.get("ports") or [])
],
})
return out
def _custom_links() -> list[dict[str, Any]]:
"""User-defined web links, including those pointing inside guests."""
import json as _json
from pathlib import Path
try:
data = _json.loads(
Path("/etc/proxmenux/custom_links.json").read_text(errors="replace"))
except (OSError, ValueError):
return []
entries = data if isinstance(data, list) else data.get("links", [])
return [{"name": e.get("name", ""), "url": e.get("url", ""),
"category": e.get("category", ""), "vmid": e.get("vmid")}
for e in entries if isinstance(e, dict)]
def _memory_modules(ctx) -> dict[str, Any]:
"""Populated and empty slots, so remaining capacity is visible.
dmidecode reports every slot the board has; a slot without a module
carries the literal "No Module Installed" as its size.
"""
rc, out = ctx.run(["dmidecode", "-t", "memory"], timeout=15)
devices: list[dict[str, str]] = []
current: Optional[dict[str, str]] = None
for line in (out or "").splitlines():
stripped = line.strip()
if stripped == "Memory Device":
current = {}
devices.append(current)
continue
if current is None or ":" not in stripped:
continue
key, value = stripped.split(":", 1)
current[key.strip()] = value.strip()
modules, empty = [], 0
for dev in devices:
size = dev.get("Size", "")
if not size or size.lower().startswith("no module"):
empty += 1
continue
modules.append({
"locator": dev.get("Locator", ""),
"size": size,
"type": dev.get("Type", ""),
"form_factor": dev.get("Form Factor", ""),
"speed": dev.get("Configured Memory Speed") or dev.get("Speed", ""),
"manufacturer": dev.get("Manufacturer", ""),
"part_number": dev.get("Part Number", ""),
})
return {"slots": len(devices) or None, "populated": len(modules),
"empty": empty, "modules": modules}
def _lsblk_pairs(ctx) -> list[dict[str, str]]:
"""lsblk key="value" output; model strings contain spaces."""
rc, out = ctx.run(
["lsblk", "-dn", "-P", "-b", "-o",
"NAME,MODEL,SERIAL,SIZE,ROTA,TRAN,TYPE"], timeout=15)
rows = []
for line in (out or "").splitlines():
fields = dict(re.findall(r'(\w+)="([^"]*)"', line))
# zd* are ZFS volumes: guest disks the kernel exposes as block
# devices. They are not hardware and report no SMART.
if fields.get("TYPE") == "disk" and not fields.get("NAME", "").startswith("zd"):
rows.append(fields)
return rows
def _disk_observations() -> dict[str, list[dict[str, Any]]]:
"""Recorded disk events, keyed by device.
The Monitor keeps these because a transient error that clears is
still part of a disk's history: SMART reports the present state,
the observation log reports what happened. A report that only shows
the present state hides the pattern that precedes a failure.
"""
server = sys.modules.get("flask_server") or sys.modules.get("__main__")
store = getattr(server, "health_persistence", None)
getter = getattr(store, "get_disk_observations", None)
if getter is None:
return {}
try:
records = getter() or []
except Exception:
return {}
grouped: dict[str, list[dict[str, Any]]] = {}
for record in records:
device = (record.get("device_name") or "").replace("/dev/", "")
if not device:
continue
grouped.setdefault(device, []).append({
"type": record.get("error_type", ""),
"severity": record.get("severity", ""),
"count": record.get("occurrence_count", 0),
"first_seen": record.get("first_occurrence"),
"last_seen": record.get("last_occurrence"),
"message": (record.get("raw_message") or "")[:400],
})
for entries in grouped.values():
entries.sort(key=lambda e: e.get("last_seen") or 0, reverse=True)
return grouped
def _physical_disks(ctx) -> list[dict[str, Any]]:
observations = _disk_observations()
# The SMART cache is keyed by device, each entry a (collected_at, data)
# pair as the Monitor stores it.
smart = {}
cached = (getattr(ctx, "monitor_snapshot", None) or {}).get("smart") or {}
for device, value in cached.items():
data = value[1] if isinstance(value, (list, tuple)) and len(value) == 2 else value
if isinstance(data, dict):
smart[str(device).replace("/dev/", "")] = data
disks = []
for row in _lsblk_pairs(ctx):
size = row.get("SIZE", "")
name = row.get("NAME", "")
health = smart.get(name) or {}
disks.append({
"name": name,
"model": (row.get("MODEL") or "").strip(),
"serial": (row.get("SERIAL") or "").strip(),
"size_bytes": int(size) if size.isdigit() else None,
"rotational": row.get("ROTA") == "1",
"bus": (row.get("TRAN") or "").strip(),
"health": health.get("smart_status"),
"temperature": health.get("temperature"),
"power_on_hours": health.get("power_on_hours"),
"observations": observations.get(name, []),
})
return sorted(disks, key=lambda d: d["name"])
def _network_adapters() -> list[dict[str, Any]]:
"""Physical adapters only: an interface backed by a real device."""
from pathlib import Path as _Path
def read(path):
try:
return _Path(path).read_text(errors="replace").strip()
except OSError:
return ""
adapters = []
try:
entries = sorted(_Path("/sys/class/net").iterdir())
except OSError:
return adapters
for iface in entries:
device = iface / "device"
if not device.exists():
continue
speed = read(iface / "speed")
driver = ""
try:
driver = (device / "driver").resolve().name
except OSError:
pass
pci = ""
try:
pci = device.resolve().name
except OSError:
pass
adapters.append({
"name": iface.name,
"mac": read(iface / "address"),
"state": read(iface / "operstate"),
# An interface that is down reports -1, which is not a speed.
"speed_mbps": int(speed) if speed.lstrip("-").isdigit()
and int(speed) > 0 else None,
"driver": driver,
"pci": pci,
})
return adapters
# Device classes worth naming in a report: what moves the storage and
# what a guest could be given directly.
_CONTROLLER_CLASSES = (
"RAID bus controller", "Serial Attached SCSI controller",
"SATA controller", "SCSI storage controller",
"Non-Volatile memory controller", "Fibre Channel",
"VGA compatible controller", "3D controller", "Display controller",
"Ethernet controller", "Network controller",
)
def _controllers(ctx) -> list[dict[str, Any]]:
rc, out = ctx.run(["lspci", "-D"], timeout=15)
devices = []
for line in (out or "").splitlines():
if " " not in line:
continue
slot, rest = line.split(" ", 1)
if ":" not in rest:
continue
klass, name = rest.split(":", 1)
klass = klass.strip()
if klass in _CONTROLLER_CLASSES:
devices.append({"slot": slot, "class": klass, "name": name.strip()})
return devices
def _cluster(ctx) -> Optional[dict[str, Any]]:
"""The cluster this node belongs to, or None when it stands alone.
Membership is read from corosync's own configuration; quorum state
comes from pvecm, which reports what the node currently sees.
"""
from pathlib import Path as _Path
conf = _Path("/etc/pve/corosync.conf")
if not conf.exists():
conf = _Path("/etc/corosync/corosync.conf")
if not conf.exists():
return None
try:
text = conf.read_text(errors="replace")
except OSError:
return None
name = ""
m = re.search(r"cluster_name:\s*(\S+)", text)
if m:
name = m.group(1)
nodes = []
for block in re.findall(r"node\s*{([^}]*)}", text):
entry = {
"name": _kv(block, r"\s*name") or _kv(block, r"\s*ring0_addr"),
"nodeid": _kv(block, r"\s*nodeid"),
"ring0_addr": _kv(block, r"\s*ring0_addr"),
"ring1_addr": _kv(block, r"\s*ring1_addr") or None,
}
entry["local"] = entry["name"] == ctx.node
nodes.append(entry)
quorate, expected, total = None, None, None
rc, status = ctx.run(["pvecm", "status"], timeout=15, allowed_codes=(0, 2))
for line in (status or "").splitlines():
low = line.lower()
if low.startswith("quorate:"):
quorate = line.split(":", 1)[1].strip().lower() == "yes"
elif low.startswith("expected votes:"):
expected = line.split(":", 1)[1].strip()
elif low.startswith("total votes:"):
total = line.split(":", 1)[1].strip()
# pvecm lists the members it currently sees; a configured node absent
# from that list is configured but not reachable right now.
online = set()
rc2, members = ctx.run(["pvecm", "nodes"], timeout=15, allowed_codes=(0, 2))
for line in (members or "").splitlines():
parts = line.split()
if len(parts) >= 3 and parts[0].isdigit():
# The local node is marked with a trailing "(local)" token.
online.add(parts[-2] if parts[-1] == "(local)" else parts[-1])
if online:
for node in nodes:
node["online"] = node["name"] in online
return {"name": name or "unnamed", "nodes": sorted(nodes, key=lambda n: n["name"]),
"quorate": quorate, "expected_votes": expected, "total_votes": total,
"links": 2 if any(n.get("ring1_addr") for n in nodes) else 1}
def _hardware(ctx) -> dict[str, Any]:
"""System identity and processor, from data the host already exposes."""
def dmi(field):
rc, out = ctx.run(["dmidecode", "-s", field], timeout=10)
value = (out or "").strip().splitlines()
value = value[-1].strip() if value else ""
# dmidecode returns these placeholders when a board ships without
# the field populated; they are not identities.
return "" if value.lower() in ("default string", "to be filled by o.e.m.",
"not specified", "unknown") else value
cpu_model, sockets, cores, threads = "", 0, 0, 0
physical: set[str] = set()
rc, cpuinfo = ctx.run(["cat", "/proc/cpuinfo"], timeout=10)
for line in (cpuinfo or "").splitlines():
if line.startswith("model name") and not cpu_model:
cpu_model = line.split(":", 1)[1].strip()
elif line.startswith("physical id"):
physical.add(line.split(":", 1)[1].strip())
elif line.startswith("processor"):
threads += 1
elif line.startswith("cpu cores") and not cores:
cores = int(line.split(":", 1)[1].strip() or 0)
sockets = len(physical) or 1
virt = ""
if cpuinfo:
if " vmx" in cpuinfo:
virt = "vmx"
elif " svm" in cpuinfo:
virt = "svm"
return {
"system": {"manufacturer": dmi("system-manufacturer"),
"product": dmi("system-product-name"),
"serial": dmi("system-serial-number")},
"board": {"manufacturer": dmi("baseboard-manufacturer"),
"product": dmi("baseboard-product-name")},
"bios": {"vendor": dmi("bios-vendor"), "version": dmi("bios-version"),
"date": dmi("bios-release-date")},
"cpu": {"model": cpu_model, "sockets": sockets,
"cores_per_socket": cores, "threads": threads,
"virtualisation": virt or None},
"memory_bytes": _host_memory(ctx),
"memory": _memory_modules(ctx),
"disks": _physical_disks(ctx),
"adapters": _network_adapters(),
"controllers": _controllers(ctx),
"iommu_groups": len(set(_iommu_groups().values())) or None,
}
def _host_memory(ctx) -> int:
rc, out = ctx.run(["cat", "/proc/meminfo"], timeout=10)
for line in (out or "").splitlines():
if line.startswith("MemTotal:"):
parts = line.split()
if len(parts) >= 2 and parts[1].isdigit():
return int(parts[1]) * 1024
return 0
def _proxmenux(ctx) -> dict[str, Any]:
"""What ProxMenux itself has applied to this host."""
import json as _json
from pathlib import Path
def load(path):
try:
return _json.loads(Path(path).read_text(errors="replace"))
except (OSError, ValueError):
return None
from post_install_versions import load_installed_tools
installed = load_installed_tools()
updates = load("/usr/local/share/proxmenux/updates_available.json") or {}
tools = []
for key in sorted(installed):
value = installed[key]
if not value.get("installed", False):
continue
version = value.get("version")
tools.append({"key": key, "version": str(version) if version is not None else ""})
return {
"optimizations": tools,
"pending_updates": [
{"key": u.get("key"), "current": u.get("current_version"),
"available": u.get("available_version")}
for u in (updates.get("updates") or [])
],
}
+361
View File
@@ -0,0 +1,361 @@
"""Declared policy for Audit & Report.
An assessment can see what a host does; it cannot see what the host is
*for*. Whether a guest needs a backup, whether a service has to come back
by itself after a reboot, whether a storage is essential or convenient
none of that is discoverable, and guessing at it is what turns an
ordinary configuration into an alarm.
So the audit reports an absence it cannot interpret as an observation,
and only calls it a warning once somebody has declared what was expected.
Nothing here is required: a host with no policy at all still produces a
complete report, just one that describes rather than judges.
The declaration lives in ``/usr/local/share/proxmenux/audit_policy.json``
and is written by hand or by the interface. It is read, never inferred:
if the file is missing, malformed or partial, every unstated question
stays unstated.
A guest marked as exempt is not a risk somebody accepted. It is a guest
outside the scope of the expectation, so it leaves the count entirely
rather than appearing as something to justify.
"""
from __future__ import annotations
import json
import fcntl
import hashlib
import math
import os
import tempfile
import threading
import time
from pathlib import Path
from typing import Any, Optional
POLICY_PATH = Path("/usr/local/share/proxmenux/audit_policy.json")
SCHEMA_VERSION = 1
# What a declaration can say about an expectation.
REQUIRED = "required"
NOT_REQUIRED = "not_required"
UNSPECIFIED = "unspecified"
_EXPECTATIONS = (REQUIRED, NOT_REQUIRED, UNSPECIFIED)
# What a site can declare about the host itself, as opposed to about a
# guest. Each is read as "is this expected here": `firewall: required`
# expects the switch on, `ssh_root_login: not_required` expects that
# access not to be available.
HOST_EXPECTATIONS = ("firewall", "ssh_root_login")
# What a storage is for, which decides how gravely its loss reads.
ROLE_ESSENTIAL = "essential"
ROLE_OPTIONAL = "optional"
ROLE_UNSPECIFIED = "unspecified"
_ROLES = (ROLE_ESSENTIAL, ROLE_OPTIONAL, ROLE_UNSPECIFIED)
# Thresholds a site may want to move. The defaults are the values the
# checks used before policy existed, so a host without a declaration
# behaves exactly as it did.
DEFAULT_THRESHOLDS: dict[str, float] = {
"storage_usage_percent": 90,
"thin_pool_usage_percent": 90,
"thin_overprovision_ratio": 2.0,
"zfs_scrub_days": 35,
"backup_fallback_days": 30,
"backup_schedule_grace_ratio": 0.5,
"certificate_expiry_days": 30,
"memory_overcommit_ratio": 1.5,
"disk_service_life_hours": 43800,
"lynis_report_days": 30,
"package_index_days": 7,
"journal_usage_percent": 80,
"filesystem_usage_percent": 90,
"filesystem_inode_percent": 90,
"disk_error_recent_days": 7,
}
_lock = threading.Lock()
class PolicyConflict(ValueError):
"""The declaration changed after the editor read it."""
def _valid_number(value, name: str = "") -> bool:
try:
return (type(value) in (int, float) and math.isfinite(value)
and value > 0 and (not name.endswith("_percent") or value <= 100))
except OverflowError:
return False
class Policy:
"""One reading of the declaration, answering only what it was told."""
def __init__(self, raw: Optional[dict] = None, source: str = "",
error: Optional[str] = None, revision: str = "missing"):
raw = raw if isinstance(raw, dict) else {}
self.source = source
self.error = error
self.revision = revision
self.declared = bool(raw)
self._guests = raw.get("guests") if isinstance(raw.get("guests"), dict) else {}
self._storages = raw.get("storages") if isinstance(raw.get("storages"), dict) else {}
self._defaults = raw.get("defaults") if isinstance(raw.get("defaults"), dict) else {}
self._host = raw.get("host") if isinstance(raw.get("host"), dict) else {}
thresholds = raw.get("thresholds") if isinstance(raw.get("thresholds"), dict) else {}
self._thresholds = {}
for name, value in thresholds.items():
# A malformed threshold falls back to the default rather than
# silently disabling the check it belongs to.
if name in DEFAULT_THRESHOLDS and _valid_number(value, name):
self._thresholds[name] = float(value)
# -- guests ------------------------------------------------------
def _guest(self, vmid) -> dict:
entry = self._guests.get(str(vmid))
return entry if isinstance(entry, dict) else {}
def expectation(self, vmid, name: str) -> str:
"""Whether something is expected of a guest, as declared.
Falls back to the site default for that expectation, and to
``unspecified`` when neither says anything.
"""
value = self._guest(vmid).get(name)
if value not in _EXPECTATIONS:
value = self._defaults.get(name)
return value if value in _EXPECTATIONS else UNSPECIFIED
def backup_required(self, vmid) -> str:
return self.expectation(vmid, "backup")
def autostart_required(self, vmid) -> str:
return self.expectation(vmid, "autostart")
def guest_note(self, vmid) -> str:
note = self._guest(vmid).get("note")
return note if isinstance(note, str) else ""
def recovery_objective_hours(self, vmid) -> Optional[float]:
"""How old a guest's newest backup may be before it is a warning.
Declared per guest because it is a property of the workload, not
of the schedule that happens to protect it.
"""
value = self._guest(vmid).get("recovery_objective_hours")
if value is None:
value = self._defaults.get("recovery_objective_hours")
return float(value) if _valid_number(value) else None
# -- the host itself ---------------------------------------------
def host_expectation(self, name: str) -> str:
"""What the site declares about the host's own configuration.
Kept apart from ``defaults``, which are per-guest fallbacks. The
vocabulary is the same one the guest expectations use, read the
same way: ``ssh_root_login: not_required`` says that access is
not meant to be available here, and ``firewall: required`` says
the switch is meant to be on. Undeclared means the check states
the fact and does not judge it.
"""
value = self._host.get(name)
return value if value in _EXPECTATIONS else UNSPECIFIED
def exempt_guests(self, name: str) -> set:
"""Guests explicitly declared as not needing something."""
return {vmid for vmid, entry in self._guests.items()
if isinstance(entry, dict) and entry.get(name) == NOT_REQUIRED}
# -- storages ----------------------------------------------------
def storage_role(self, storage_id: str) -> str:
entry = self._storages.get(storage_id)
role = entry.get("role") if isinstance(entry, dict) else None
if role not in _ROLES:
role = self._defaults.get("storage_role")
return role if role in _ROLES else ROLE_UNSPECIFIED
# -- thresholds --------------------------------------------------
def threshold(self, name: str) -> float:
if name in self._thresholds:
return self._thresholds[name]
return float(DEFAULT_THRESHOLDS[name])
def is_default(self, name: str) -> bool:
"""Whether a threshold is the shipped value or a declared one."""
return name not in self._thresholds
# -- reporting ---------------------------------------------------
def describe(self) -> dict[str, Any]:
"""What the report says about the policy it applied."""
return {
"declared": self.declared,
"source": self.source or str(POLICY_PATH),
"guests_declared": len(self._guests),
"storages_declared": len(self._storages),
"thresholds_declared": sorted(self._thresholds),
"host_declared": sorted(k for k in self._host if k in HOST_EXPECTATIONS),
"error": self.error,
"revision": self.revision,
}
def load(path: Path = POLICY_PATH) -> Policy:
"""Read one complete snapshot of the small declaration file.
An unreadable or malformed file is reported as an error and treated as
no declaration at all. Falling back to an assumed policy would be
worse than having none: it would judge the host against expectations
nobody set.
"""
try:
content = path.read_bytes()
except FileNotFoundError:
return Policy(source=str(path))
except OSError as exc:
return Policy(source=str(path), error=f"{type(exc).__name__}: {exc}")
revision = hashlib.sha256(content).hexdigest()
try:
raw = json.loads(content)
_clean(raw)
return Policy(raw, source=str(path), revision=revision)
except (ValueError, UnicodeError, OverflowError) as exc:
return Policy(source=str(path), error=f"{type(exc).__name__}: {exc}",
revision=revision)
def _clean(raw: dict) -> dict:
if not isinstance(raw, dict):
raise ValueError("the declaration must be an object")
cleaned: dict[str, Any] = {"version": SCHEMA_VERSION,
"updated_at": int(time.time())}
guests = raw.get("guests", {})
if not isinstance(guests, dict):
raise ValueError("guests must be an object keyed by VMID")
kept_guests: dict[str, dict] = {}
for vmid, entry in guests.items():
if not str(vmid).isdigit() or not isinstance(entry, dict):
raise ValueError(f"invalid guest declaration: {vmid}")
kept: dict[str, Any] = {}
for name in ("backup", "autostart"):
value = entry.get(name)
if value in _EXPECTATIONS:
kept[name] = value
elif value is not None:
raise ValueError(f"invalid expectation for guest {vmid}: {name}={value}")
rpo = entry.get("recovery_objective_hours")
if rpo is not None:
if not _valid_number(rpo):
raise ValueError(f"invalid recovery objective for guest {vmid}: {rpo}")
kept["recovery_objective_hours"] = float(rpo)
note = entry.get("note")
if isinstance(note, str) and note.strip():
kept["note"] = note.strip()[:500]
if kept:
kept_guests[str(vmid)] = kept
cleaned["guests"] = kept_guests
storages = raw.get("storages", {})
if not isinstance(storages, dict):
raise ValueError("storages must be an object keyed by storage id")
kept_storages: dict[str, dict] = {}
for storage_id, entry in storages.items():
if not isinstance(entry, dict):
raise ValueError(f"invalid storage declaration: {storage_id}")
role = entry.get("role")
if role in _ROLES:
kept_storages[str(storage_id)] = {"role": role}
elif role is not None:
raise ValueError(f"invalid role for storage {storage_id}: {role}")
cleaned["storages"] = kept_storages
thresholds = raw.get("thresholds", {})
if not isinstance(thresholds, dict):
raise ValueError("thresholds must be an object")
kept_thresholds: dict[str, float] = {}
for name, value in thresholds.items():
if name not in DEFAULT_THRESHOLDS:
raise ValueError(f"unknown threshold: {name}")
if not _valid_number(value, name):
raise ValueError(f"invalid value for {name}: {value}")
kept_thresholds[name] = float(value)
cleaned["thresholds"] = kept_thresholds
defaults = raw.get("defaults", {})
if not isinstance(defaults, dict):
raise ValueError("defaults must be an object")
kept_defaults: dict[str, Any] = {}
for name in ("backup", "autostart"):
if defaults.get(name) in _EXPECTATIONS:
kept_defaults[name] = defaults[name]
elif defaults.get(name) is not None:
raise ValueError(f"invalid default expectation: {name}")
if defaults.get("storage_role") in _ROLES:
kept_defaults["storage_role"] = defaults["storage_role"]
elif defaults.get("storage_role") is not None:
raise ValueError("invalid default storage role")
if defaults.get("recovery_objective_hours") is not None:
if not _valid_number(defaults["recovery_objective_hours"]):
raise ValueError("invalid default recovery objective")
kept_defaults["recovery_objective_hours"] = float(
defaults["recovery_objective_hours"])
cleaned["defaults"] = kept_defaults
host = raw.get("host", {})
if not isinstance(host, dict):
raise ValueError("host must be an object")
kept_host: dict[str, Any] = {}
for name in HOST_EXPECTATIONS:
if host.get(name) in _EXPECTATIONS:
kept_host[name] = host[name]
elif host.get(name) is not None:
raise ValueError(f"invalid host expectation: {name}")
cleaned["host"] = kept_host
return cleaned
def save(raw: dict, path: Path = POLICY_PATH,
expected_revision: Optional[str] = None) -> Policy:
"""Validate and atomically replace a declaration, rejecting stale editors.
The process lock and flock cover revision comparison and replacement.
Each writer owns a private 0600 temporary file in the target directory.
"""
cleaned = _clean(raw)
content = json.dumps(cleaned, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
path.parent.mkdir(parents=True, exist_ok=True)
with _lock:
lock_fd = os.open(str(path) + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
with os.fdopen(lock_fd, "a") as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
current = load(path)
if expected_revision is not None and current.revision != expected_revision:
raise PolicyConflict("The declaration changed in another session; reload before saving.")
if current.error:
raise ValueError(current.error)
temporary = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8",
dir=path.parent, prefix=".audit-policy-",
delete=False) as handle:
temporary = Path(handle.name)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return load(path)
+125
View File
@@ -0,0 +1,125 @@
"""Report profiles for Audit & Report.
A profile answers one question, so it selects the checks and the
inventory sections that bear on it. The alternative always producing
everything leaves the reader to find the relevant part, and is how a
report grows section by section until nobody reads it.
Profiles are declared as data rather than as code so the backend and the
interface work from the same definition, and so adding a check does not
require revisiting every profile: a profile names areas, and only names
individual checks when it needs one that lives elsewhere.
"""
from __future__ import annotations
from typing import Any, Optional
# Every inventory section the composer can produce. A profile lists the
# subset its question needs.
ALL_SECTIONS = (
"identity", "cluster", "hardware", "network", "latency", "storages", "guests",
"passthrough", "applications", "custom_links", "proxmenux",
)
PROFILES: dict[str, dict[str, Any]] = {
# The whole picture. What an assessment produces when no narrower
# question has been asked.
"full": {
"areas": None, # None means every area
"include": (),
"sections": ALL_SECTIONS,
},
# Everything is assessed and almost nothing is printed. The reader
# of this one is deciding what to do in the next few minutes, so it
# carries the findings that ask for a decision and the readings that
# could not be taken, and leaves out the inventory, the diagrams and
# the annex. Scope stays full deliberately: a short report that
# skipped checks would be quick and untrustworthy.
"diagnostic": {
"areas": None,
"include": (),
"sections": ("identity",),
"brief": True,
},
# Describes the node without judging it. Runs no checks, so it is
# available on a host that has never been assessed.
"inventory": {
"areas": (), # empty means no checks
"include": (),
"sections": ALL_SECTIONS,
},
# Exposure and access. Container privilege and the enterprise
# repository sit in other areas but bear on the same question.
"security": {
"areas": ("security",),
"include": (
"guests.privileged_containers",
"system.security_updates",
"system.enterprise_repo_without_subscription",
"system.update_chain",
),
"sections": ("identity", "cluster", "network", "latency", "guests"),
},
# Whether guests are protected, and whether the protection is real.
# Storage is included because a destination that cannot be reached
# accepts no backup.
"backup": {
"areas": ("backup",),
"include": ("storage.connected_storage", "system.notification_delivery"),
"sections": ("identity", "cluster", "guests", "storages"),
},
# Room to grow and the age of what it grows on.
"capacity": {
"areas": ("storage", "hardware"),
"include": ("system.memory_overcommit", "system.journal_size",
"system.swap_configured", "system.filesystem_capacity"),
"sections": ("identity", "cluster", "hardware", "storages", "guests"),
},
}
DEFAULT_PROFILE = "full"
def is_known(profile: str) -> bool:
return profile in PROFILES
def selected_checks(profile: str, checks) -> list:
"""Checks a profile runs, from the registered catalogue.
``areas`` of ``None`` selects everything and an empty tuple selects
nothing, which is what lets the inventory profile produce a document
without assessing the host.
"""
spec = PROFILES.get(profile) or PROFILES[DEFAULT_PROFILE]
areas = spec["areas"]
include = set(spec["include"])
if areas is None:
return list(checks)
areas = set(areas)
return [c for c in checks if c.area in areas or c.check_id in include]
def sections(profile: str) -> tuple:
spec = PROFILES.get(profile) or PROFILES[DEFAULT_PROFILE]
return tuple(spec["sections"])
def describe() -> list[dict[str, Any]]:
"""Profile catalogue for the interface, without any host data."""
return [
{
"id": name,
"areas": None if spec["areas"] is None else list(spec["areas"]),
"include": list(spec["include"]),
"sections": list(spec["sections"]),
"runs_checks": spec["areas"] != (),
"brief": bool(spec.get("brief")),
}
for name, spec in PROFILES.items()
]
+305 -26
View File
@@ -19,6 +19,9 @@ and is stored verbatim.
from __future__ import annotations
import json
import hashlib
import re
import os
import sqlite3
import threading
import time
@@ -28,22 +31,147 @@ 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.
# What a check concluded, on one scale.
#
# Severity used to be declared per check and state per run, which meant a
# storage at 90% capacity was labelled "critical" because the check that
# found it is the one that can also find an unreachable storage. Gravity
# belongs to the situation, so the check now returns it with the result,
# and it may differ between the objects one check reports on.
#
# The scale is deliberately short, and each step says what it takes to
# earn it:
#
# critical an interruption or an urgent threat to availability,
# integrity or recoverability, backed by evidence
# warning a verified degradation, an expected protection that is
# absent, or a declared policy that is not met
# observation a configuration, a limit or planning information; it
# does not demonstrate a problem and is not counted as one
# conformant the criterion was verified and is met
# unverified not enough information to conclude; not a fault
# not_applicable nothing on this host to evaluate
CLASS_CRITICAL = "critical"
CLASS_WARNING = "warning"
CLASS_OBSERVATION = "observation"
CLASS_CONFORMANT = "conformant"
CLASS_UNVERIFIED = "unverified"
CLASS_NOT_APPLICABLE = "not_applicable"
CLASSIFICATIONS = (CLASS_CRITICAL, CLASS_WARNING, CLASS_OBSERVATION,
CLASS_CONFORMANT, CLASS_UNVERIFIED, CLASS_NOT_APPLICABLE)
# Worst first: a finding takes the gravity of its gravest object.
CLASS_ORDER = {name: i for i, name in enumerate(CLASSIFICATIONS)}
# Only these two are problems. An observation is information, and
# unverified is an absence of information; counting either as a problem is
# what made ordinary configurations look like faults.
CLASS_PROBLEMS = (CLASS_CRITICAL, CLASS_WARNING)
# What the reader decided about a finding, kept apart from what the
# assessment concluded. A technical result does not change because someone
# accepted it; only the decision layered over it does.
DECISION_NONE = ""
DECISION_ACCEPTED = "accepted" # a signed exception over a real finding
DECISION_BY_DESIGN = "by_design" # declared policy: this object is exempt
# Retained so findings recorded before the scale existed still read, and
# so the interface can be migrated without breaking the stored history.
STATE_FAIL = "fail"
STATE_WARN = "warn"
STATE_PASS = "pass"
STATE_NOT_APPLICABLE = "not_applicable"
STATE_ACCEPTED = "accepted"
STATE_UNKNOWN = "unknown"
# A finding written before the scale is read on the scale, using the
# severity its check declared at the time.
_LEGACY_STATE_MAP = {
STATE_PASS: CLASS_CONFORMANT,
STATE_UNKNOWN: CLASS_UNVERIFIED,
STATE_NOT_APPLICABLE: CLASS_NOT_APPLICABLE,
STATE_ACCEPTED: CLASS_WARNING,
}
def classification_of(state: str, severity: str) -> str:
"""Read a stored state and severity on the current scale."""
mapped = _LEGACY_STATE_MAP.get(state)
if mapped:
return mapped
if state == STATE_FAIL:
return CLASS_CRITICAL if severity == "CRITICAL" else CLASS_WARNING
if state == STATE_WARN:
return CLASS_OBSERVATION if severity == "INFO" else CLASS_WARNING
return CLASS_UNVERIFIED
def state_of(classification: str) -> str:
"""The state a classification would have had, for stored compatibility."""
return {
CLASS_CRITICAL: STATE_FAIL,
CLASS_WARNING: STATE_WARN,
CLASS_OBSERVATION: STATE_WARN,
CLASS_CONFORMANT: STATE_PASS,
CLASS_UNVERIFIED: STATE_UNKNOWN,
CLASS_NOT_APPLICABLE: STATE_NOT_APPLICABLE,
}.get(classification, STATE_UNKNOWN)
def worst(classifications) -> str:
"""The gravest of several, or not applicable when there are none."""
ranked = [c for c in classifications if c in CLASS_ORDER]
if not ranked:
return CLASS_NOT_APPLICABLE
return min(ranked, key=lambda c: CLASS_ORDER[c])
RUN_RUNNING = "running"
RUN_COMPLETE = "complete"
RUN_FAILED = "failed"
RUN_PARTIAL = "partial"
_schema_lock = threading.Lock()
_schema_ready = False
def safe_evidence(value):
"""Redact secrets before persistence; bound individual evidence fields."""
if isinstance(value, dict):
return {k: ("[redacted]" if re.search(r"password|secret|token|authorization|private.key", k, re.I)
else safe_evidence(v)) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [safe_evidence(v) for v in value]
if not isinstance(value, str):
return value
value = re.sub(r"(?s)-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
"[private key redacted]", value)
value = re.sub(r"(https?://)[^/\s@]+@", r"\1[redacted]@", value)
value = re.sub(r"(?i)((?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*)[^\s&,;]+",
r"\1[redacted]", value)
value = re.sub(r"(?im)(authorization\s*:\s*).*", r"\1[redacted]", value)
return value if len(value) <= 32768 else value[:32768] + "\n[evidence truncated]"
def finding_scope(finding):
"""Bind decisions to object identity, rule version, host and gravity.
A decision is about a situation, not about a check. If the same
objects come back at a different gravity, the situation is not the one
that was accepted, so the acceptance does not carry over.
"""
objects = []
for obj in finding.get("affected") or []:
identity = {k: obj[k] for k in ("vmid", "type", "volume", "device", "pool",
"job", "test", "bridge", "file", "snapshot", "storage", "package") if k in obj}
objects.append(identity or obj)
payload = {"objects": sorted(objects, key=lambda v: json.dumps(v, sort_keys=True)),
"check": finding["check_id"], "version": finding.get("check_version", 1),
"classification": finding.get("classification", ""),
"host": finding.get("host", "")}
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH), timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
@@ -59,6 +187,9 @@ def init_db() -> None:
if _schema_ready:
return
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(DB_PATH, os.O_CREAT | os.O_WRONLY, 0o600)
os.close(fd)
os.chmod(DB_PATH, 0o600)
conn = _connect()
try:
conn.executescript("""
@@ -113,7 +244,34 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_audit_runs_started
ON audit_runs(started_at);
""")
# Additive migration: retain existing runs and decisions.
for table, columns in {
"audit_runs": {"metadata": "TEXT", "checks_expected": "INTEGER NOT NULL DEFAULT 0"},
"audit_findings": {"raw_state": "TEXT", "exception_snapshot": "TEXT",
"scope": "TEXT", "details": "TEXT", "classification": "TEXT",
"raw_classification": "TEXT", "decision": "TEXT"},
"audit_exceptions": {"scope": "TEXT"},
}.items():
present = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
for name, kind in columns.items():
if name not in present:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {kind}")
conn.execute("""CREATE TABLE IF NOT EXISTS audit_exception_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, check_id TEXT NOT NULL,
action TEXT NOT NULL, happened_at INTEGER NOT NULL, decision TEXT NOT NULL)""")
conn.row_factory = sqlite3.Row
for legacy in conn.execute("SELECT * FROM audit_exceptions WHERE scope IS NULL"):
exists = conn.execute("SELECT 1 FROM audit_exception_events WHERE check_id = ? LIMIT 1",
(legacy["check_id"],)).fetchone()
if not exists:
conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) "
"VALUES (?, 'legacy-unscoped', ?, ?)",
(legacy["check_id"], legacy["accepted_at"], json.dumps(safe_evidence(dict(legacy)))))
# Old accepted findings have no recoverable technical state.
conn.execute("UPDATE audit_findings SET raw_state = CASE WHEN state = 'accepted' "
"THEN 'unknown' ELSE state END WHERE raw_state IS NULL")
conn.commit()
os.chmod(DB_PATH, 0o600)
_schema_ready = True
finally:
conn.close()
@@ -123,16 +281,17 @@ def init_db() -> None:
# Runs
# ---------------------------------------------------------------------------
def start_run(profile: str) -> str:
def start_run(profile: str, metadata=None, checks_expected=0) -> 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),
"INSERT INTO audit_runs (run_id, profile, started_at, status, metadata, "
"checks_expected, schema_version) VALUES (?, ?, ?, ?, ?, ?, 2)",
(run_id, profile, int(time.time()), RUN_RUNNING,
json.dumps(safe_evidence(metadata or {})), checks_expected),
)
conn.commit()
finally:
@@ -140,8 +299,19 @@ def start_run(profile: str) -> str:
return run_id
def update_run_metadata(run_id, metadata, checks_expected):
init_db()
conn = _connect()
try:
conn.execute("UPDATE audit_runs SET metadata = ?, checks_expected = ? WHERE run_id = ?",
(json.dumps(safe_evidence(metadata)), checks_expected, run_id))
conn.commit()
finally:
conn.close()
def finish_run(run_id: str, *, checks_total: int,
error: Optional[str] = None) -> None:
error: Optional[str] = None, partial: bool = False) -> None:
"""Close a run, marking it failed when an error is supplied."""
init_db()
conn = _connect()
@@ -149,8 +319,8 @@ def finish_run(run_id: str, *, checks_total: int,
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),
(int(time.time()), RUN_FAILED if error else RUN_PARTIAL if partial else RUN_COMPLETE,
safe_evidence(error), checks_total, run_id),
)
conn.commit()
finally:
@@ -165,7 +335,7 @@ def get_run(run_id: str) -> Optional[dict[str, Any]]:
row = conn.execute(
"SELECT * FROM audit_runs WHERE run_id = ?", (run_id,)
).fetchone()
return dict(row) if row else None
return _run_row(row) if row else None
finally:
conn.close()
@@ -179,22 +349,36 @@ def list_runs(limit: int = 20) -> list[dict[str, Any]]:
"SELECT * FROM audit_runs ORDER BY started_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
return [_run_row(r) for r in rows]
finally:
conn.close()
def latest_run(status: str = RUN_COMPLETE) -> Optional[dict[str, Any]]:
def _run_row(row) -> dict[str, Any]:
"""A run as its consumers need it, with metadata as an object.
The column holds JSON text; handing that to an interface means every
caller parses it, and the one that forgets silently reads nothing
rather than failing.
"""
run = dict(row)
try:
run["metadata"] = json.loads(run.get("metadata") or "{}")
except (TypeError, ValueError):
run["metadata"] = {}
return run
def latest_run(status: Optional[str] = None) -> 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
condition = "status = ?" if status else "status != 'running'"
row = conn.execute(f"SELECT * FROM audit_runs WHERE {condition} "
"ORDER BY started_at DESC, rowid DESC LIMIT 1",
(status,) if status else ()).fetchone()
return _run_row(row) if row else None
finally:
conn.close()
@@ -212,18 +396,32 @@ def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int:
init_db()
if not findings:
return 0
findings = safe_evidence(findings)
rows = [
(
run_id,
f["check_id"],
f["area"],
f["severity"],
f["state"],
# state is derived from the classification and kept so a
# database written by this version still reads on the old
# columns; the scale is what the interface reads.
state_of(f["classification"]),
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"),
# raw_state stays a state, on the old vocabulary; the scale
# travels in its own column.
state_of(f.get("raw_classification", f["classification"])),
json.dumps(f.get("exception")),
f.get("scope"),
json.dumps({k: f[k] for k in ("check_version", "collected_at", "sources",
"incomplete", "observations", "host") if k in f}),
f["classification"],
f.get("raw_classification", f["classification"]),
f.get("decision", DECISION_NONE),
)
for f in findings
]
@@ -233,7 +431,9 @@ def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int:
conn.executemany(
"INSERT INTO audit_findings (run_id, check_id, area, severity, "
"state, summary_key, summary_params, affected, evidence, "
"remediable_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"remediable_by, raw_state, exception_snapshot, scope, details, "
"classification, raw_classification, decision) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rows,
)
conn.commit()
@@ -263,6 +463,18 @@ def get_findings(run_id: str) -> list[dict[str, Any]]:
item.get("summary_params") or "{}")
except (TypeError, ValueError):
item["summary_params"] = {}
item.update(json.loads(item.pop("details", None) or "{}"))
item["exception"] = json.loads(item.pop("exception_snapshot", None) or "null")
# A finding recorded before the scale existed is read on it,
# from the state and severity it was stored with.
if not item.get("classification"):
item["classification"] = classification_of(
item.get("state", ""), item.get("severity", ""))
item["raw_classification"] = (
item.get("raw_classification")
or classification_of(item.get("raw_state") or item.get("state", ""),
item.get("severity", "")))
item.setdefault("decision", DECISION_NONE)
out.append(item)
return out
finally:
@@ -291,7 +503,7 @@ def check_history(check_id: str, limit: int = 30) -> list[dict[str, Any]]:
# ---------------------------------------------------------------------------
def accept_risk(check_id: str, reason: str, accepted_by: str,
expires_at: Optional[int] = None) -> None:
expires_at: Optional[int] = None, *, scope: str) -> None:
"""Record a deliberate decision to leave a finding unresolved.
A reason is mandatory: an acceptance without one is indistinguishable
@@ -300,25 +512,44 @@ def accept_risk(check_id: str, reason: str, accepted_by: str,
"""
if not (reason or "").strip():
raise ValueError("an accepted risk requires a reason")
if not scope:
raise ValueError("an accepted risk requires an assessed scope")
if expires_at is not None and expires_at <= time.time():
raise ValueError("expiry must be in the future")
init_db()
conn = _connect()
try:
conn.execute("BEGIN IMMEDIATE")
decision = dict(check_id=check_id, reason=reason.strip(), accepted_by=accepted_by,
accepted_at=int(time.time()), expires_at=expires_at, scope=scope)
conn.execute(
"INSERT OR REPLACE INTO audit_exceptions "
"(check_id, reason, accepted_by, accepted_at, expires_at) "
"VALUES (?, ?, ?, ?, ?)",
"(check_id, reason, accepted_by, accepted_at, expires_at, scope) "
"VALUES (?, ?, ?, ?, ?, ?)",
(check_id, reason.strip(), accepted_by, int(time.time()),
expires_at),
expires_at, scope),
)
conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) "
"VALUES (?, 'accepted', ?, ?)",
(check_id, int(time.time()), json.dumps(safe_evidence(decision))))
conn.commit()
finally:
conn.close()
def revoke_risk(check_id: str) -> bool:
def revoke_risk(check_id: str, actor: str = "local-admin") -> bool:
init_db()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
conn.execute("BEGIN IMMEDIATE")
previous = conn.execute("SELECT * FROM audit_exceptions WHERE check_id = ?", (check_id,)).fetchone()
if previous:
decision = dict(previous)
decision["revoked_by"] = actor
conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) "
"VALUES (?, 'revoked', ?, ?)",
(check_id, int(time.time()), json.dumps(safe_evidence(decision))))
cur = conn.execute(
"DELETE FROM audit_exceptions WHERE check_id = ?", (check_id,)
)
@@ -349,6 +580,54 @@ def active_exceptions() -> dict[str, dict[str, Any]]:
conn.close()
def effective_findings(run_id):
"""Current decisions over immutable technical results; history stays intact.
The classification is what the assessment concluded and does not
change because somebody accepted it. What changes is the decision
recorded beside it, which is why the two are separate fields: a
report can still show that a critical finding was accepted, and by
whom, instead of showing a finding that looks resolved.
"""
exceptions = active_exceptions()
findings = get_findings(run_id)
for f in findings:
f["classification"] = f["raw_classification"]
f["state"] = f["raw_state"]
f["exception"] = None
f["decision"] = DECISION_NONE
decision = exceptions.get(f["check_id"])
if (decision and decision.get("scope") and decision["scope"] == f.get("scope")
and f["classification"] in CLASS_PROBLEMS and not f.get("incomplete")):
f["decision"] = DECISION_ACCEPTED
f["state"] = STATE_ACCEPTED
f["exception"] = decision
return findings
def exception_history():
init_db()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
return [dict(row) for row in conn.execute(
"SELECT * FROM audit_exception_events ORDER BY id DESC LIMIT 200")]
finally:
conn.close()
def recover_interrupted_runs():
"""Called at service startup, never during an active assessment."""
init_db()
conn = _connect()
try:
conn.execute("UPDATE audit_runs SET status = ?, error = ?, finished_at = ? WHERE status = ?",
(RUN_FAILED, "Assessment interrupted by Monitor restart", int(time.time()), RUN_RUNNING))
conn.commit()
finally:
conn.close()
def all_exceptions() -> list[dict[str, Any]]:
init_db()
now = int(time.time())
@@ -414,7 +693,7 @@ def prune_runs(keep: int = 30) -> int:
try:
conn.execute("BEGIN IMMEDIATE")
cur = conn.execute(
"DELETE FROM audit_runs WHERE is_baseline = 0 AND run_id NOT IN ("
"DELETE FROM audit_runs WHERE is_baseline = 0 AND status != 'running' AND run_id NOT IN ("
" SELECT run_id FROM audit_runs "
" WHERE is_baseline = 0 ORDER BY started_at DESC LIMIT ?"
")",
+2
View File
@@ -307,6 +307,8 @@ def verify_password(password, password_hash):
can log in once and trigger a rehash via `_maybe_rehash_password`
see lazy migration in `authenticate()`.
"""
if not isinstance(password, str) or not password:
return False
if not isinstance(password_hash, str) or not password_hash:
return False
if password_hash.startswith(_PWD_PBKDF2_PREFIX):
+6
View File
@@ -168,7 +168,13 @@ cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
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_profiles.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_profiles.py not found"
cp "$SCRIPT_DIR/audit_policy.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_policy.py not found"
cp "$SCRIPT_DIR/changes_journal.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ changes_journal.py not found"
cp "$SCRIPT_DIR/audit_inventory.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_inventory.py not found"
cp "$SCRIPT_DIR/audit_checks_pve.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks_pve.py not found"
# Preserve the existing build version as assessment provenance; no version bump.
cp "$APPIMAGE_ROOT/package.json" "$APP_DIR/package.json"
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
+367
View File
@@ -0,0 +1,367 @@
"""ProxMenux change journal — reading side.
The scripts that change this host write one small JSON file per change
into a spool directory, and copy whatever they replaced into a content
store keyed by digest. Nothing there needs a database, a daemon or a
network: recording has to work during a first installation, before
anything else exists, and it must never be the reason an operation fails.
This module is the other half. It consolidates the spool into a table
that can be queried, and answers the question the whole thing exists
for: *what did ProxMenux change on this machine, and what was there
before.*
Two distinctions are load-bearing and are kept throughout:
* **What was changed** against **what was run.** A post-install
function that rewrites a file authored that change. An upgrade
launched from a menu did not: apt decided what changed, and claiming
it would be taking credit and blame for someone else's work. Both are
recorded; they are not the same kind of entry.
* **How well the previous state is known.** A change recorded as it
happened carries the original. A function re-applied on a host that
was already modified carries what was there at the time, which is not
the original. Anything applied before the journal existed carries
nothing at all. A reader who is deciding whether to revert needs to
know which of the three they are looking at.
"""
from __future__ import annotations
import difflib
import json
import os
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Optional
ROOT = Path("/usr/local/share/proxmenux/changes")
SPOOL = ROOT / "spool"
OBJECTS = ROOT / "objects"
DB_PATH = Path("/usr/local/share/proxmenux/changes.db")
# What kind of act an entry records.
CLASS_CONFIGURATION = "configuration" # ProxMenux changed this
CLASS_INSTALLATION = "installation" # ProxMenux put this here
CLASS_EXECUTION = "execution" # ProxMenux ran this; it did not decide the outcome
CLASS_REGISTRATION = "registration" # applied, with no record of what changed
CLASSES = (CLASS_CONFIGURATION, CLASS_INSTALLATION,
CLASS_EXECUTION, CLASS_REGISTRATION)
# How much of the previous state is actually known.
CAPTURE_PRESENT = "present" # what was there when the change was made
CAPTURE_CREATED = "created" # nothing was there; the change created it
CAPTURE_UNKNOWN = "unknown" # applied before the journal, or unknowable
CAPTURE_NONE = "none" # nothing to capture (an execution)
# A file large enough that keeping it whole in the journal would cost
# more than the answer is worth; the digest and size are still recorded.
MAX_OBJECT_BYTES = 2 * 1024 * 1024
# Diffs are for reading, not for archiving: past this many lines the
# reader is better served by the counts than by the hunks.
MAX_DIFF_LINES = 400
_lock = threading.Lock()
_ready = False
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH), timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db() -> None:
global _ready
with _lock:
if _ready:
return
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
first = not DB_PATH.exists()
conn = _connect()
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recorded_at INTEGER NOT NULL,
ingested_at INTEGER NOT NULL,
class TEXT NOT NULL,
operation TEXT NOT NULL,
source TEXT,
function TEXT,
function_version TEXT,
target TEXT,
before_ref TEXT,
after_ref TEXT,
capture TEXT,
revert TEXT,
exactness TEXT,
result TEXT,
detail TEXT,
-- The spool file this came from, so an entry is
-- ingested once however often the reader runs.
origin TEXT UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_changes_time
ON changes(recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_changes_function
ON changes(function);
""")
conn.commit()
finally:
conn.close()
if first:
try:
DB_PATH.chmod(0o600)
except OSError:
pass
_ready = True
def object_path(digest: str) -> Optional[Path]:
"""Where a captured content lives, if it is still there."""
if not digest or len(digest) < 4 or not digest.isalnum():
return None
path = OBJECTS / digest[:2] / digest
return path if path.is_file() else None
def read_object(digest: str) -> Optional[str]:
"""Captured content as text, or None when it is gone or too large."""
path = object_path(digest)
if path is None:
return None
try:
if path.stat().st_size > MAX_OBJECT_BYTES:
return None
return path.read_text(errors="replace")
except OSError:
return None
def ingest(limit: int = 5000) -> int:
"""Move what the scripts wrote into the table.
A malformed entry is dropped rather than allowed to stop the rest:
the spool is written by shell running under conditions this process
cannot see, and one bad file must not cost the reader every other
change on the host.
"""
init_db()
if not SPOOL.is_dir():
return 0
try:
pending = sorted(p for p in SPOOL.iterdir()
if p.suffix == ".json" and p.is_file())[:limit]
except OSError:
return 0
if not pending:
return 0
rows, consumed = [], []
for path in pending:
try:
entry = json.loads(path.read_text(errors="replace"))
except (OSError, ValueError):
# Keep it out of the way but do not delete it: a file that
# could not be read is evidence of its own.
_quarantine(path)
continue
if not isinstance(entry, dict):
_quarantine(path)
continue
rows.append((
int(entry.get("recorded_at") or time.time()),
int(time.time()),
str(entry.get("class") or CLASS_CONFIGURATION),
str(entry.get("operation") or "unknown"),
str(entry.get("source") or ""),
str(entry.get("function") or ""),
str(entry.get("function_version") or ""),
str(entry.get("target") or ""),
str(entry.get("before") or ""),
str(entry.get("after") or ""),
str(entry.get("capture") or CAPTURE_UNKNOWN),
str(entry.get("revert") or "none"),
str(entry.get("exactness") or "none"),
str(entry.get("result") or "ok"),
json.dumps({k: v for k, v in entry.items()
if k not in ("recorded_at", "class", "operation", "source",
"function", "function_version", "target",
"before", "after", "capture", "revert",
"exactness", "result")}, ensure_ascii=False),
path.name,
))
consumed.append(path)
if not rows:
return 0
conn = _connect()
try:
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"INSERT OR IGNORE INTO changes (recorded_at, ingested_at, class, "
"operation, source, function, function_version, target, before_ref, "
"after_ref, capture, revert, exactness, result, detail, origin) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows)
conn.commit()
finally:
conn.close()
for path in consumed:
try:
path.unlink()
except OSError:
pass
return len(rows)
def _quarantine(path: Path) -> None:
bad = ROOT / "unreadable"
try:
bad.mkdir(parents=True, exist_ok=True)
path.rename(bad / path.name)
except OSError:
pass
def diff_of(entry: dict[str, Any]) -> Optional[dict[str, Any]]:
"""What changed in a file, as the difference and nothing else.
A function may run to four hundred lines and alter two values; the
reader is owed the two values, not the function. Where the content is
gone or too large to hold, the absence is reported rather than
guessed at.
"""
if entry.get("class") != CLASS_CONFIGURATION:
return None
before_ref, after_ref = entry.get("before_ref"), entry.get("after_ref")
before = read_object(before_ref) if before_ref else ""
after = read_object(after_ref) if after_ref else ""
if before is None or after is None:
return {"available": False,
"reason": "content no longer stored or too large to show"}
before_lines = before.splitlines()
after_lines = after.splitlines()
hunks = list(difflib.unified_diff(before_lines, after_lines,
lineterm="", n=2))[2:]
added = sum(1 for l in hunks if l.startswith("+"))
removed = sum(1 for l in hunks if l.startswith("-"))
return {
"available": True,
"added": added,
"removed": removed,
"before_lines": len(before_lines),
"after_lines": len(after_lines),
"truncated": len(hunks) > MAX_DIFF_LINES,
"hunks": hunks[:MAX_DIFF_LINES],
}
def changes(limit: int = 200, offset: int = 0,
function: str = "", klass: str = "") -> list[dict[str, Any]]:
"""Recorded changes, newest first."""
init_db()
ingest()
query = "SELECT * FROM changes WHERE 1=1"
params: list[Any] = []
if function:
query += " AND function = ?"
params.append(function)
if klass:
query += " AND class = ?"
params.append(klass)
query += " ORDER BY recorded_at DESC, id DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
conn = _connect()
try:
conn.row_factory = sqlite3.Row
rows = [dict(r) for r in conn.execute(query, params)]
finally:
conn.close()
for row in rows:
try:
row["detail"] = json.loads(row.get("detail") or "{}")
except ValueError:
row["detail"] = {}
# Whether the previous state can still be shown at all, which is
# what decides if a revert is even discussable.
row["recoverable"] = bool(row.get("before_ref")
and object_path(row["before_ref"]))
return rows
def summary() -> dict[str, Any]:
"""What the host has been through, in the shape the page opens with."""
init_db()
ingest()
conn = _connect()
try:
conn.row_factory = sqlite3.Row
by_class = {row["class"]: row["n"] for row in conn.execute(
"SELECT class, COUNT(*) AS n FROM changes GROUP BY class")}
functions = [dict(row) for row in conn.execute(
"SELECT function, source, MAX(function_version) AS version, "
"COUNT(*) AS changes, MAX(recorded_at) AS last_change, "
"MIN(recorded_at) AS first_change "
"FROM changes WHERE function <> '' "
"GROUP BY function ORDER BY last_change DESC")]
total = sum(by_class.values())
finally:
conn.close()
return {
"total": total,
"by_class": by_class,
"functions": functions,
# Where the journal itself stands, so a host with nothing recorded
# can say why rather than looking like a host nothing touched.
"journal_started": _journal_started(),
}
def _journal_started() -> Optional[int]:
"""When this host first recorded anything, if it ever has."""
conn = _connect()
try:
row = conn.execute("SELECT MIN(recorded_at) AS first FROM changes").fetchone()
return row[0] if row and row[0] else None
finally:
conn.close()
def prune(keep_days: int = 365) -> int:
"""Drops entries and their content past the retention window.
Content is only removed once no entry references it, since the same
original may be shared by several changes.
"""
init_db()
cutoff = int(time.time()) - keep_days * 86400
conn = _connect()
try:
conn.execute("BEGIN IMMEDIATE")
removed = conn.execute("DELETE FROM changes WHERE recorded_at < ?",
(cutoff,)).rowcount
referenced = {row[0] for row in conn.execute(
"SELECT before_ref FROM changes WHERE before_ref <> '' "
"UNION SELECT after_ref FROM changes WHERE after_ref <> ''")}
conn.commit()
finally:
conn.close()
if OBJECTS.is_dir():
for shard in OBJECTS.iterdir():
if not shard.is_dir():
continue
for obj in shard.iterdir():
if obj.name not in referenced:
try:
obj.unlink()
except OSError:
pass
return removed
+209 -23
View File
@@ -14,7 +14,8 @@ import threading
import time
from flask import Blueprint, jsonify, request
from jwt_middleware import require_auth
from jwt_middleware import require_auth, require_admin_scope
from auth_manager import verify_token, load_auth_config
audit_bp = Blueprint('audit', __name__)
@@ -22,14 +23,47 @@ try:
import audit_store
import audit_checks
import audit_checks_pve # noqa: F401 — importing registers the checks
import audit_inventory
import audit_profiles
import audit_policy
import changes_journal
except ImportError:
audit_store = None
audit_checks = None
audit_inventory = None
audit_profiles = None
audit_policy = None
changes_journal = 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}
_startup_error = None
def _actor():
config = load_auth_config()
if not config.get('enabled') or config.get('declined'):
return 'local-admin (authentication disabled)'
parts = request.headers.get('Authorization', '').split()
return verify_token(parts[1]) if len(parts) == 2 else 'unknown'
def _progress(run_id, completed, total, check_id):
_running.update(run_id=run_id, completed=completed, total=total, check_id=check_id)
@audit_bp.record_once
def _on_register(state):
global _startup_error
if audit_store:
try:
audit_store.recover_interrupted_runs()
except Exception as exc:
# An audit DB problem must never prevent the Monitor starting.
_startup_error = str(exc)
print(f"[audit] persistence unavailable: {exc}")
def _unavailable():
@@ -66,17 +100,22 @@ def list_checks():
@require_auth
def status():
"""Latest run, whether an assessment is in progress, and the baseline."""
if not audit_store:
if not audit_store or _startup_error:
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
for f in audit_store.effective_findings(latest['run_id']):
# An accepted finding is counted as a decision, not as the
# problem it still technically is, so the counters and the
# list a reader sees agree with each other.
key = (f.get('decision') or f['classification'])
summary[key] = summary.get(key, 0) + 1
return jsonify({
"success": True,
"running": _running['active'],
"progress": {k: _running.get(k) for k in ('run_id', 'completed', 'total', 'check_id')},
"latest": latest,
"summary": summary,
"baseline": audit_store.get_baseline(),
@@ -87,7 +126,7 @@ def status():
@audit_bp.route('/api/audit/run', methods=['POST'])
@require_auth
@require_admin_scope
def run():
"""Start an assessment in the background.
@@ -95,13 +134,17 @@ def run():
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:
if not audit_checks or _startup_error:
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
if (not audit_profiles.is_known(profile) or (areas is not None and
(not isinstance(areas, list) or not areas or
any(not isinstance(a, str) or a not in audit_checks.AREAS for a in areas)))):
return jsonify(success=False, message="Unsupported audit profile or areas"), 400
only = set(areas) if areas is not None else None
with _run_lock:
if _running['active']:
@@ -110,21 +153,27 @@ def run():
"message": "An assessment is already running",
"run_id": _running['run_id'],
}), 409
_running.update({'active': True, 'run_id': None,
'started_at': time.time()})
run_id = audit_store.start_run(profile)
_running.update({'active': True, 'run_id': run_id,
'started_at': time.time(), 'completed': 0, 'total': 0, 'check_id': None})
def worker():
try:
run_id = audit_checks.run_assessment(profile, only_areas=only)
_running['run_id'] = run_id
audit_checks.run_assessment(profile, only_areas=only, run_id=run_id, progress=_progress)
audit_store.prune_runs()
except Exception as e:
audit_store.finish_run(run_id, checks_total=_running.get('completed', 0), error=str(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})
try:
threading.Thread(target=worker, daemon=True, name='audit-run').start()
except Exception as e:
_running['active'] = False
audit_store.finish_run(run_id, checks_total=0, error=str(e))
return jsonify(success=False, message="Unable to start assessment"), 500
return jsonify({"success": True, "started": True, "run_id": run_id})
@audit_bp.route('/api/audit/runs', methods=['GET'])
@@ -154,10 +203,10 @@ def run_detail(run_id):
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'])
# History is immutable by default. The live view explicitly asks
# for current decisions, so acceptance/revocation needs no scan.
findings = (audit_store.effective_findings(run_id) if request.args.get('effective') == '1'
else audit_store.get_findings(run_id))
return jsonify({"success": True, "run": run, "findings": findings})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@@ -181,6 +230,7 @@ def compare():
if not base or not other:
return jsonify({
"success": False,
"reason": "insufficient_runs",
"message": "Two runs are required to compare",
}), 400
return jsonify({
@@ -194,7 +244,7 @@ def compare():
@audit_bp.route('/api/audit/baseline', methods=['POST'])
@require_auth
@require_admin_scope
def set_baseline():
if not audit_store:
return _unavailable()
@@ -218,13 +268,14 @@ def list_exceptions():
return jsonify({
"success": True,
"exceptions": audit_store.all_exceptions(),
"history": audit_store.exception_history(),
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/exceptions', methods=['POST'])
@require_auth
@require_admin_scope
def accept_exception():
"""Record a finding as a deliberate decision.
@@ -244,11 +295,20 @@ def accept_exception():
if not reason:
return jsonify({"success": False,
"message": "A reason is required"}), 400
latest = audit_store.latest_run()
if not latest or data.get('run_id') != latest['run_id']:
return jsonify(success=False, message="Reload the latest assessment before accepting a risk"), 409
finding = next((f for f in audit_store.get_findings(latest['run_id']) if f['check_id'] == check_id), None)
if (not finding or finding.get('raw_classification') not in audit_store.CLASS_PROBLEMS or
finding.get('incomplete') or not finding.get('scope')):
return jsonify(success=False, message="This finding cannot be accepted"), 400
expires_at = None
days = data.get('expires_in_days')
if days:
if days is not None:
try:
if isinstance(days, bool) or int(days) != float(days) or not 1 <= int(days) <= 3650:
raise ValueError("invalid expiry")
expires_at = int(time.time()) + int(days) * 86400
except (TypeError, ValueError):
return jsonify({"success": False,
@@ -256,8 +316,9 @@ def accept_exception():
audit_store.accept_risk(
check_id, reason,
accepted_by=str(data.get('accepted_by') or 'admin'),
accepted_by=_actor(),
expires_at=expires_at,
scope=finding['scope'],
)
return jsonify({"success": True})
except ValueError as e:
@@ -267,15 +328,140 @@ def accept_exception():
@audit_bp.route('/api/audit/exceptions/<path:check_id>', methods=['DELETE'])
@require_auth
@require_admin_scope
def revoke_exception(check_id):
if not audit_store:
return _unavailable()
try:
removed = audit_store.revoke_risk(check_id)
removed = audit_store.revoke_risk(check_id, _actor())
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
@audit_bp.route('/api/audit/inventory', methods=['GET'])
@require_auth
def inventory():
"""Structural inventory of the node.
Composed from collectors the Monitor already runs; the assessment and
the inventory answer different questions and neither depends on the
other, so this endpoint does not require a run to exist.
"""
if not audit_inventory:
return _unavailable()
try:
profile = request.args.get('profile') or audit_profiles.DEFAULT_PROFILE
if not audit_profiles.is_known(profile):
return jsonify(success=False, message="Unsupported report profile"), 400
ctx = audit_checks.AuditContext()
ctx.begin_check()
inventory = audit_inventory.collect(ctx, sections=audit_profiles.sections(profile))
return jsonify({"success": True, "profile": profile, "inventory": inventory})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/profiles', methods=['GET'])
@require_auth
def profiles():
"""Report profiles this build offers, without touching the host."""
if not audit_profiles:
return _unavailable()
try:
return jsonify({"success": True, "default": audit_profiles.DEFAULT_PROFILE,
"profiles": audit_profiles.describe()})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/policy', methods=['GET'])
@require_auth
def policy():
"""The declaration, and what a declaration can say.
The vocabulary travels with the declaration so the interface offers
exactly the expectations and thresholds this build understands,
rather than a list written twice and drifting apart.
"""
if not audit_policy:
return _unavailable()
try:
current = audit_policy.load()
if current.error:
return jsonify(success=False, message=current.error), 422
return jsonify({
"success": True,
"policy": {
"guests": current._guests,
"storages": current._storages,
"defaults": current._defaults,
"thresholds": current._thresholds,
},
"summary": current.describe(),
"vocabulary": {
"expectations": list(audit_policy._EXPECTATIONS),
"roles": list(audit_policy._ROLES),
"thresholds": audit_policy.DEFAULT_THRESHOLDS,
},
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/policy', methods=['PUT'])
@require_admin_scope
def save_policy():
"""Replace the declaration.
Validation is the store's, not this endpoint's: a declaration that
cannot be understood is refused with the reason rather than written
and reinterpreted later.
"""
if not audit_policy:
return _unavailable()
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify(success=False, message="A policy object is required"), 400
revision = payload.get("expected_revision")
if not isinstance(revision, str) or not revision:
return jsonify(success=False, message="A policy revision is required"), 428
try:
saved = audit_policy.save(payload, expected_revision=revision)
except audit_policy.PolicyConflict as e:
return jsonify(success=False, message=str(e)), 409
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
return jsonify({"success": True, "summary": saved.describe()})
@audit_bp.route('/api/audit/changes', methods=['GET'])
@require_auth
def changes():
"""What ProxMenux changed on this host, and what was there before.
The diff of each configuration change travels with it: a function may
run to hundreds of lines and alter two values, and it is the two
values the reader is owed.
"""
if not changes_journal:
return _unavailable()
try:
limit = min(int(request.args.get('limit', 200)), 1000)
entries = changes_journal.changes(
limit=limit,
offset=int(request.args.get('offset', 0)),
function=request.args.get('function', ''),
klass=request.args.get('class', ''),
)
for entry in entries:
entry["diff"] = changes_journal.diff_of(entry)
return jsonify({"success": True, "changes": entries,
"summary": changes_journal.summary()})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
+16
View File
@@ -495,10 +495,26 @@ def auth_change_password():
"""
try:
data = request.json or {}
# `old_password` is the canonical API field. Accept the original
# frontend name as a compatibility alias so an already-open browser
# tab can still complete the request after a Monitor update.
old_password = data.get('old_password')
if old_password is None:
old_password = data.get('current_password')
new_password = data.get('new_password')
totp_code = data.get('totp_code')
if not isinstance(old_password, str) or not isinstance(new_password, str):
return jsonify({
"success": False,
"message": "Current password and new password are required",
}), 400
if totp_code is not None and not isinstance(totp_code, str):
return jsonify({
"success": False,
"message": "Invalid 2FA code",
}), 400
success, message = auth_manager.change_password(old_password, new_password, totp_code)
if success:
+12 -1
View File
@@ -2254,11 +2254,17 @@ def _vm_disk_refresher_loop():
cycle_started = time.time()
try:
resources = get_cached_pvesh_cluster_resources_vm() or []
local_node = get_proxmox_node_name()
live_vmids = set()
targets = []
for r in resources:
if r.get('type') not in ('qemu', 'vm'):
continue
# Cluster resources contains guests from every member. `qm
# guest cmd` and the resulting health ownership are local-node
# operations, so never probe a VM currently owned elsewhere.
if r.get('node') != local_node:
continue
if r.get('status') != 'running':
continue
vmid = r.get('vmid')
@@ -6669,7 +6675,12 @@ def get_proxmox_vms():
# producing a false "1 package pending"
# every time a registered app had a newer
# upstream version.
app_list = lxc_app_map.get(str(resource.get('vmid')))
# Docker inventory can be ready before this CT has an
# app sidecar (especially during startup). Keep the
# core VM/LXC inventory independent from that optional
# decoration: an absent app entry is an empty list,
# never a reason to discard every guest in /api/vms.
app_list = lxc_app_map.get(str(resource.get('vmid'))) or []
if app_list:
vm_data['app_watches'] = app_list
# Apps dashboard reads this to build
+15 -1
View File
@@ -6149,6 +6149,7 @@ class HealthMonitor:
try:
import flask_server # deferred — avoids circular import at module load
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
local_node = flask_server.get_proxmox_node_name()
except Exception as e:
print(f"[HealthMonitor] LXC disk check failed: {e}")
return None
@@ -6170,6 +6171,12 @@ class HealthMonitor:
for r in resources:
if r.get('type') != 'lxc':
continue
# `/cluster/resources` is cluster-wide. Capacity belongs to the
# node currently running the CT, so every Monitor must ignore
# guests owned by another node or the same condition is recorded
# and notified independently by every cluster member.
if r.get('node') != local_node:
continue
if r.get('status') != 'running':
# Stopped CTs — `disk` reads as 0 from pvesh because the
# rootfs isn't mounted. Skip rather than report a
@@ -6194,6 +6201,7 @@ class HealthMonitor:
'maxdisk_bytes': maxdisk,
'vmid': vmid,
'name': name,
'node': local_node,
}
error_key = f'lxc_disk_{vmid}'
@@ -6287,13 +6295,16 @@ class HealthMonitor:
try:
import flask_server # deferred — avoids circular import
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
local_node = flask_server.get_proxmox_node_name()
except Exception as e:
print(f"[HealthMonitor] VM disk check failed: {e}")
return None
# Cheap short-circuit: no running QEMU VMs on this node.
if not any(
r.get('type') in ('qemu', 'vm') and r.get('status') == 'running'
r.get('type') in ('qemu', 'vm')
and r.get('node') == local_node
and r.get('status') == 'running'
for r in resources
):
return None
@@ -6308,6 +6319,8 @@ class HealthMonitor:
for r in resources:
if r.get('type') not in ('qemu', 'vm'):
continue
if r.get('node') != local_node:
continue
if r.get('status') != 'running':
continue
@@ -6338,6 +6351,7 @@ class HealthMonitor:
'maxdisk_bytes': total,
'vmid': vmid_str,
'name': name,
'node': local_node,
}
error_key = f'vm_disk_{vmid_str}'
+137 -49
View File
@@ -17,8 +17,8 @@
# update_app(vmid, app_id, config) -> (bool, …)
# delete_app(vmid, app_id) -> bool
# delete_all(vmid) -> bool
# check_app(vmid, app_id, force=False) -> dict|None
# check_all(vmid, force=False) -> dict|None
# check_app(vmid, app_id, force=False, notify=True) -> dict|None
# check_all(vmid, force=False, notify=True) -> dict|None
# get_active_apps() -> {str(vmid): [summary, …]}
# get_suggestions(vmid) -> {name, port_suggestions[], web_path_hint}
# ==========================================================
@@ -3872,43 +3872,69 @@ def clear_schedule_reboot_required(vmid) -> bool:
return _write_sidecar(vmid, sidecar)
def _fire_update_notification(vmid, app: dict) -> None:
def _app_update_notification_payload(vmid, app: dict) -> Optional[dict]:
"""Return the notification payload for one pending app update.
The same eligibility rules are used by direct/manual checks and by the
scheduled batch so per-app opt-outs and Docker-owned updates cannot drift
between the two paths.
"""
# Per-app opt-out: user flipped the bell icon off for this specific
# app (because they know it can't be updated on their box or they
# just don't care). Field defaults to True — an app registered
# before this feature landed keeps receiving notifications.
if app.get("notifications_enabled", True) is False:
return
return None
if app.get("helper_slug") == "docker":
return
return None
# Delegated apps are announced by their Docker image's own event; a
# second one for the same release would land in a different event type
# and therefore escape deduplication.
if app.get("update_via") == "docker":
return
return None
state = app.get("state") or {}
latest = state.get("latest_version")
if not state.get("update_available") or not latest:
return None
return {
"vmid": int(vmid),
"ct_name": app.get("name") or f"CT-{vmid}",
"app_name": app.get("name") or "app",
"installed": state.get("installed_version") or "unknown",
"latest": latest,
"app_id": str(app.get("id") or ""),
}
def _emit_app_update_event(data: dict, entity: str, entity_id: str) -> bool:
try:
from notification_manager import notification_manager
import socket
state = app.get("state") or {}
notification_manager.emit_event(
event_type='app_update_available',
severity='INFO',
data={
'hostname': socket.gethostname(),
'vmid': int(vmid),
'ct_name': app.get('name') or f'CT-{vmid}',
'app_name': app.get('name') or 'app',
'installed': state.get('installed_version') or 'unknown',
'latest': state.get('latest_version') or 'unknown',
},
data={"hostname": socket.gethostname(), **data},
source='app_watch',
entity='ct',
# vmid + app_id + latest so multi-app CTs don't dedup and
# subsequent upstream releases still fire.
entity_id=f"{vmid}:{app.get('id')}:{state.get('latest_version') or ''}",
entity=entity,
entity_id=entity_id,
)
return True
except Exception as e:
print(f"[ProxMenux] lxc_apps: notif emit failed for CT {vmid}: {e}")
print(f"[ProxMenux] lxc_apps: app update notification failed: {e}")
return False
def _fire_update_notification(vmid, app: dict) -> bool:
payload = _app_update_notification_payload(vmid, app)
if payload is None:
return False
app_id = payload.pop("app_id")
return _emit_app_update_event(
payload,
entity="ct",
# vmid + app_id + latest so multi-app CTs don't dedup and
# subsequent upstream releases still fire.
entity_id=f"{vmid}:{app_id}:{payload['latest']}",
)
def _docker_stack_notification_payload(
@@ -4166,7 +4192,9 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
return installed, err, False
def check_app(vmid, app_id: str, force: bool = False) -> Optional[dict]:
def check_app(
vmid, app_id: str, force: bool = False, notify: bool = True,
) -> Optional[dict]:
with _cache_lock:
sidecar = _read_sidecar(vmid)
if not sidecar:
@@ -4230,18 +4258,20 @@ def check_app(vmid, app_id: str, force: bool = False) -> Optional[dict]:
# (vmid + app_id + latest_version) with its cooldown, and only
# a genuinely new upstream release changes the entity_id and
# triggers a fresh delivery.
if update_available and latest:
if notify and update_available and latest:
_fire_update_notification(vmid, app)
return sidecar
def emit_all_pending_updates() -> int:
"""Walk every sidecar and emit `app_update_available` for each
app currently marked with a pending upstream release. Safe to
call repeatedly `notification_manager` dedups by entity_id
(vmid + app_id + latest_version), so a given release only sends
once until a newer version appears.
"""Emit pending registered-app updates as one scheduled summary.
A single pending app retains the original per-app notification. Multiple
apps are grouped into one event, ordered by CT and app, while preserving
every installed/latest version pair. Safe to call repeatedly: the batch
entity id is derived from the exact pending set and notification_manager
applies its normal cooldown.
Needed because `check_app(force=False)` short-circuits on a fresh
`checked_at` and never reaches the emit path. The 24 h
@@ -4249,14 +4279,14 @@ def emit_all_pending_updates() -> int:
this helper the notification only ever fired on the exact tick
where a new upstream version was FIRST observed and even that
was silenced when the user's setting was OFF at the time.
Returns the number of emits attempted (delivery still depends on
channel enablement + cooldown + rate limit)."""
Returns the number of eligible pending apps represented by the event
(delivery still depends on channel enablement + cooldown + rate limit)."""
try:
entries = sorted(os.listdir(_APPS_DIR))
except (FileNotFoundError, OSError):
print("[ProxMenux] emit_all_pending_updates: _APPS_DIR missing", flush=True)
return 0
n = 0
pending_payloads: list[dict] = []
print(f"[ProxMenux] emit_all_pending_updates: scanning {len(entries)} sidecar file(s)", flush=True)
for name in entries:
if not name.endswith(".json"):
@@ -4271,36 +4301,81 @@ def emit_all_pending_updates() -> int:
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} sidecar empty", flush=True)
continue
apps = sidecar.get("apps") or []
pending = [a for a in apps
if (a.get("state") or {}).get("update_available")
and (a.get("state") or {}).get("latest_version")]
pending = [
app for app in apps
if (app.get("state") or {}).get("update_available")
and (app.get("state") or {}).get("latest_version")
]
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} apps={len(apps)} pending={len(pending)}", flush=True)
for app in pending:
try:
_fire_update_notification(vmid, app)
n += 1
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} emit '{app.get('name')}'", flush=True)
except Exception as inner:
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} emit '{app.get('name')}' FAILED: {inner}", flush=True)
payload = _app_update_notification_payload(vmid, app)
if payload is not None:
pending_payloads.append(payload)
except Exception as e:
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} outer failure: {e}", flush=True)
print(f"[ProxMenux] emit_all_pending_updates: {n} emit(s) attempted total", flush=True)
return n
pending_payloads.sort(
key=lambda item: (
item["vmid"],
item["app_name"].casefold(),
item["app_id"],
)
)
count = len(pending_payloads)
if count == 0:
print("[ProxMenux] emit_all_pending_updates: no eligible pending apps", flush=True)
return 0
if count == 1:
payload = dict(pending_payloads[0])
app_id = payload.pop("app_id")
_emit_app_update_event(
payload,
entity="ct",
entity_id=f"{payload['vmid']}:{app_id}:{payload['latest']}",
)
print("[ProxMenux] emit_all_pending_updates: 1 app in 1 notification", flush=True)
return 1
signature = "|".join(
f"{item['vmid']}:{item['app_id']}:{item['latest']}"
for item in pending_payloads
)
updates = [
{key: value for key, value in item.items() if key != "app_id"}
for item in pending_payloads
]
container_count = len({item["vmid"] for item in pending_payloads})
_emit_app_update_event(
{
"count": count,
"container_count": container_count,
"updates": updates,
},
entity="node",
entity_id=f"batch:{hashlib.sha256(signature.encode()).hexdigest()[:20]}",
)
print(
f"[ProxMenux] emit_all_pending_updates: {count} apps in 1 notification",
flush=True,
)
return count
def check_all(vmid, force: bool = False) -> Optional[dict]:
def check_all(
vmid, force: bool = False, notify: bool = True,
) -> Optional[dict]:
sidecar = _read_sidecar(vmid)
if not sidecar:
return None
for app in (sidecar.get("apps") or []):
try:
check_app(vmid, app.get("id"), force=force)
check_app(vmid, app.get("id"), force=force, notify=notify)
except Exception as e:
print(f"[ProxMenux] lxc_apps.check_all: CT {vmid} app {app.get('id')} failed: {e}")
return _read_sidecar(vmid)
def refresh_all_apps(force: bool = False) -> int:
def refresh_all_apps(force: bool = False, notify: bool = True) -> int:
"""Called from the polling collector's daily cycle so header
badges stay fresh without needing to open every modal."""
try:
@@ -4316,7 +4391,7 @@ def refresh_all_apps(force: bool = False) -> int:
except ValueError:
continue
try:
check_all(vmid, force=force)
check_all(vmid, force=force, notify=notify)
n += 1
except Exception as e:
print(f"[ProxMenux] lxc_apps refresh_all: CT {vmid} failed: {e}")
@@ -4985,12 +5060,14 @@ def _docker_service_catalog_meta(service: str, container: str, image: str) -> di
def _probe_docker_web_links(vmid) -> list[dict]:
"""Return running Docker workloads that publish TCP ports on the LXC.
"""Return Docker workloads that publish TCP ports on the LXC.
The result is suggestion-only. No sidecar entry is written and no port is
assumed to be HTTP until the user explicitly adds it in the editor. IPv4
and IPv6 bindings of the same host port are deduplicated; loopback-only
bindings are omitted because they cannot form a usable remote LXC link.
Stopped containers are included from their persistent HostConfig bindings,
so their links remain registrable before the workload is started again.
"""
key = str(vmid)
now = time.time()
@@ -4999,7 +5076,7 @@ def _probe_docker_web_links(vmid) -> list[dict]:
if cached and (now - cached[0]) < _PORT_PROBE_TTL_SEC:
return [dict(item) for item in cached[1]]
rc, out, _ = _pct_exec(vmid, ["docker", "ps", "-q"], timeout=10)
rc, out, _ = _pct_exec(vmid, ["docker", "ps", "-aq"], timeout=10)
if rc != 0:
result: list[dict] = []
else:
@@ -5023,7 +5100,18 @@ def _probe_docker_web_links(vmid) -> list[dict]:
labels = config.get("Labels") or {}
service = str(labels.get("com.docker.compose.service") or container).strip()
meta = _docker_service_catalog_meta(service, container, image)
ports = (obj.get("NetworkSettings") or {}).get("Ports") or {}
# NetworkSettings.Ports is populated while a container is
# running, but Docker empties it after the container stops.
# HostConfig.PortBindings retains the declared mapping and
# is therefore the fallback needed to keep those web-link
# suggestions available. Prefer live bindings whenever
# Docker provides them.
ports = dict((obj.get("HostConfig") or {}).get("PortBindings") or {})
for endpoint, bindings in (
(obj.get("NetworkSettings") or {}).get("Ports") or {}
).items():
if bindings:
ports[endpoint] = bindings
seen_host_ports: set[int] = set()
for container_endpoint, bindings in ports.items():
if not str(container_endpoint).endswith("/tcp") or not isinstance(bindings, list):
+169 -15
View File
@@ -513,6 +513,16 @@ class JournalWatcher:
self._oom_lines = []
self._oom_started_at = 0.0
# Keep the small amount of journal history that precedes a kernel
# diagnostic. `Call Trace:` is only a structural marker inside that
# diagnostic, never the cause itself. The old detector promoted the
# marker to an event and therefore sent an unactionable "Kernel call
# trace" every 24 h, sometimes followed by a second burst message for
# another line from the same incident.
from collections import deque as _deque
self._kernel_context = _deque(maxlen=40)
self._KERNEL_CONTEXT_WINDOW_SECS = 15
# 24h anti-cascade for disk I/O + filesystem errors. The dict
# key includes a tier suffix (`sdh:warning`, `sdh:critical`)
# so a disk in WARNING cooldown can still escalate to CRITICAL
@@ -526,7 +536,6 @@ class JournalWatcher:
# paper showed ~36% of failed drives gave no SMART warning.
# Rate-based escalation catches the dying drives that SMART
# would never flag until they were already bricked.
from collections import deque as _deque
self._disk_error_window: Dict[str, "_deque[float]"] = {}
self._DISK_ERROR_WINDOW_SECS = 86400 # 24h
# Tiers calibrated for homelab/SMB Proxmox usage:
@@ -767,7 +776,7 @@ class JournalWatcher:
self._check_auth_failure(msg, syslog_id, entry)
self._check_fail2ban(msg, syslog_id)
self._check_kernel_critical(msg, syslog_id, priority)
self._check_kernel_critical(msg, syslog_id, priority, entry)
self._check_service_failure(msg, unit)
self._check_disk_io(msg, syslog_id, priority)
self._check_cluster_events(msg, syslog_id)
@@ -849,13 +858,69 @@ class JournalWatcher:
'hostname': self._hostname,
}, entity='user', entity_id=ip)
def _check_kernel_critical(self, msg: str, syslog_id: str, priority: int):
def _remember_kernel_context(self, msg: str, now: float) -> str:
"""Record and return the recent journal excerpt for a kernel event."""
self._kernel_context.append((now, msg))
cutoff = now - self._KERNEL_CONTEXT_WINDOW_SECS
while self._kernel_context and self._kernel_context[0][0] < cutoff:
self._kernel_context.popleft()
return '\n'.join(line for _, line in self._kernel_context)[-4000:]
@staticmethod
def _kernel_diagnostic(msg: str) -> Optional[Tuple[str, str, str]]:
"""Return (kind, process, component) for an attributable kernel event.
A bare ``Call Trace:`` intentionally has no match. It is analogous to
a heading in a diagnostic block and cannot establish that a new fault
occurred. The patterns below identify the line that explains why the
kernel printed the trace.
"""
patterns = (
(r'\bWARNING:\s+CPU:', 'Kernel warning'),
(r'\bINFO:\s+task\s+.+?\s+blocked for more than\s+\d+', 'Blocked kernel task'),
(r'\btask\s+.+?\s+blocked for more than\s+\d+', 'Blocked kernel task'),
(r'\brcu(?:_preempt|_sched|):.*detected stalls?', 'RCU stall'),
(r'\bsoft lockup\b', 'CPU soft lockup'),
(r'\bhard LOCKUP\b', 'CPU hard lockup'),
(r'\bgeneral protection fault\b', 'General protection fault'),
(r'\bunable to handle kernel (?:NULL pointer dereference|paging request)', 'Kernel memory access fault'),
(r'\bOops:', 'Kernel oops'),
(r'\bUBSAN:', 'Undefined behaviour detected'),
(r'\bKASAN:', 'Kernel memory safety violation'),
)
kind = ''
for pattern, label in patterns:
if re.search(pattern, msg, re.IGNORECASE):
kind = label
break
if not kind:
return None
process = ''
process_match = re.search(r'\bPID:\s*(\d+)\s+Comm:\s*([^\s]+)', msg)
if process_match:
process = f'{process_match.group(2)} (PID {process_match.group(1)})'
else:
blocked_match = re.search(r'\btask\s+([^:\s]+)(?::\d+)?\s+blocked for more than', msg, re.IGNORECASE)
if blocked_match:
process = blocked_match.group(1)
component = ''
component_match = re.search(r'\bat\s+([^\s+]+)(?:\+0x[0-9a-f]+/0x[0-9a-f]+)?', msg, re.IGNORECASE)
if component_match:
component = component_match.group(1)
return kind, process, component
def _check_kernel_critical(self, msg: str, syslog_id: str, priority: int,
entry: Optional[Dict] = None):
"""Detect kernel panics, OOM, segfaults, hardware errors."""
# Only process messages from kernel or systemd (not app-level logs)
if syslog_id and syslog_id not in ('kernel', 'systemd', 'systemd-coredump', ''):
return
now = time.time()
journal_context = self._remember_kernel_context(msg, now)
if self._oom_lines and now - self._oom_started_at > 15:
self._oom_lines = []
self._oom_started_at = 0.0
@@ -918,6 +983,43 @@ class JournalWatcher:
for noise in _KERNEL_NOISE:
if re.search(noise, msg, re.IGNORECASE):
return
# A JSON journal entry lets us prove that the diagnostic came from the
# kernel transport. Plain-mode input remains supported for older
# journalctl fallbacks, but a systemd/application entry containing the
# words "WARNING: CPU" cannot masquerade as a kernel event.
transport = str((entry or {}).get('_TRANSPORT', '') or '')
is_kernel_source = entry is None or syslog_id == 'kernel' or transport == 'kernel'
diagnostic = self._kernel_diagnostic(msg) if is_kernel_source and not self._oom_lines else None
if diagnostic:
kind, process, component = diagnostic
observed_us = str((entry or {}).get('__REALTIME_TIMESTAMP', '') or '')
try:
observed_ts = int(observed_us) / 1_000_000 if observed_us else now
except (TypeError, ValueError):
observed_ts = now
observed_at = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(observed_ts))
details = [f'Type: {kind}']
if process:
details.append(f'Process: {process}')
if component:
details.append(f'Component: {component}')
details.extend((f'Message: {msg[:500]}', f'Recorded: {observed_at}'))
identity = f'{kind}\x1f{component}\x1f{process}\x1f{msg[:300]}'
entity_id = f'kernel_{hashlib.sha256(identity.encode(errors="replace")).hexdigest()[:16]}'
self._emit(
'kernel_warning',
'WARNING',
{
'hostname': self._hostname,
'reason': f'{kind}\n{msg[:500]}',
'kernel_details': '\n'.join(details),
'_journal_context': journal_context,
},
entity='node',
entity_id=entity_id,
)
return
# NOTE: Disk I/O errors (ATA, SCSI, blk_update_request) are NOT handled
# here. They are detected exclusively by HealthMonitor._check_disks_optimized
@@ -932,7 +1034,6 @@ class JournalWatcher:
r'Out of memory': ('system_problem', 'CRITICAL', 'Out of memory killer activated'),
r'segfault': ('system_problem', 'WARNING', 'Segmentation fault detected'),
r'BUG:': ('system_problem', 'CRITICAL', 'Kernel BUG detected'),
r'Call Trace:': ('system_problem', 'WARNING', 'Kernel call trace'),
r'EXT4-fs error': ('system_problem', 'CRITICAL', 'Filesystem error'),
r'BTRFS error': ('system_problem', 'CRITICAL', 'Filesystem error'),
r'XFS.*error': ('system_problem', 'CRITICAL', 'Filesystem error'),
@@ -2634,6 +2735,53 @@ class PollingCollector:
def _hostname(self) -> str:
return _hostname()
@staticmethod
def _guest_storage_error_is_now_foreign(error_key: str, old_meta: dict) -> bool:
"""Return True when a disappearing guest-capacity error moved nodes.
Older versions recorded `lxc_disk_<vmid>` and `vm_disk_<vmid>` on
every cluster member because the health check consumed the unfiltered
cluster resource list. A normal `resolved_keys` transition would make
those foreign records produce one final, false recovery after the
ownership filter is installed. The same distinction matters during a
real migration: leaving the old node is not recovery.
Prefer the current cluster owner over the historical details, because
a legitimate local alert can subsequently migrate. The stored node is
only a fallback for a guest no longer present in the resource list.
"""
match = re.fullmatch(r'(?:lxc|vm)_disk_(\d+)', str(error_key or ''))
if not match:
return False
try:
import flask_server # deferred: flask_server imports this module
local_node = str(flask_server.get_proxmox_node_name() or '')
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
vmid = match.group(1)
for resource in resources:
if str(resource.get('vmid', '')) != vmid:
continue
if resource.get('type') not in ('lxc', 'qemu', 'vm'):
continue
owner = str(resource.get('node') or '')
if owner and local_node:
return owner != local_node
except Exception:
local_node = ''
details = old_meta.get('details') if isinstance(old_meta, dict) else None
if isinstance(details, str):
try:
details = json.loads(details)
except (json.JSONDecodeError, TypeError):
details = None
if isinstance(details, dict):
owner = str(details.get('node') or '')
if owner and local_node:
return owner != local_node
return False
def start(self):
if self._running:
return
@@ -2988,6 +3136,15 @@ class PollingCollector:
reason = old_meta.get('reason', '')
first_seen = old_meta.get('first_seen', '')
# A guest moving to another cluster node — or a legacy foreign
# record created by the old cluster-wide capacity scan — has not
# recovered. Drop only this node's tracking state and let the
# current owner report the condition if it is still present.
if self._guest_storage_error_is_now_foreign(key, old_meta):
self._last_notified.pop(key, None)
self._notified_severity.pop(key, None)
continue
# Skip recovery for INFO/OK - they never triggered an alert
if old_meta.get('severity', '') in ('INFO', 'OK'):
self._last_notified.pop(key, None)
@@ -3642,7 +3799,11 @@ class PollingCollector:
# blocks the others.
try:
import lxc_apps
lxc_apps.refresh_all_apps(force=False)
# The automatic sweep builds one detailed summary after every app
# has been refreshed. Suppress the per-app emit here so the user
# does not receive the individual messages before that summary.
# Explicit UI checks keep the default notify=True behaviour.
lxc_apps.refresh_all_apps(force=False, notify=False)
# Docker images have an independent lifecycle from both the OS
# packages and the Docker engine. Refresh their read-only
# registry digest inventory on the same daily cadence; this never
@@ -3652,16 +3813,9 @@ class PollingCollector:
# yesterday's cycle cannot postpone the next automatic scan by an
# additional day. Normal UI reads remain cache-only for 24 hours.
lxc_apps.refresh_docker_inventories(force=True)
# After the refresh, emit `app_update_available` for every
# sidecar entry currently flagged with a pending upstream
# release. `check_app(force=False)` short-circuits on a
# fresh `checked_at` and never reaches the emit path, so
# without this call the notification only ever fired on
# the exact tick where a new version was FIRST observed —
# missed forever if the user had the toggle off at that
# moment. `notification_manager` dedups by entity_id
# (vmid + app_id + latest_version) so repeated calls only
# deliver one notification per release.
# Emit one detailed registered-app summary for this sweep. A
# single pending app retains the existing individual wording;
# several apps are grouped by CT with every version pair intact.
lxc_apps.emit_all_pending_docker_stacks()
lxc_apps.emit_all_pending_updates()
except Exception as e:
+18 -9
View File
@@ -497,6 +497,7 @@ AGGREGATION_RULES = {
'service_fail': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'},
'service_fail_batch': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'},
'system_problem': {'window': 90, 'min_count': 2, 'burst_type': 'burst_system'},
'kernel_warning': {'window': 90, 'min_count': 2, 'burst_type': 'burst_system'},
'oom_kill': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'},
'firewall_issue': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'},
}
@@ -522,12 +523,10 @@ _DEFAULT_AGGREGATION = {'window': 60, 'min_count': 2, 'burst_type': 'burst_gener
# recovery is per-event; collapsing them adds zero information.
_AGGREGATION_EXEMPT_EVENTS = frozenset({
'error_resolved',
# Per-app upstream update. Each event carries a distinct app name,
# version and CT id — collapsing "5 app updates burst" into a
# summary hides exactly the information the user wants (which
# apps, which versions). Startup emit fires all pending updates
# at once, so without this exemption only the first 1-2 land and
# the rest get buffered into a useless summary.
# Registered-app updates are grouped deliberately by their producer during
# automatic/startup sweeps, preserving each app, CT and version pair.
# Manual checks still emit one complete per-app event. Sending either form
# through the generic burst formatter would discard those details.
'app_update_available',
'docker_stack_update_available',
'lxc_update_applied',
@@ -1274,8 +1273,18 @@ class NotificationManager:
channels = dict(self._channels)
template = TEMPLATES.get(event_type, {})
event_group = template.get('group', 'other')
default_event_enabled = 'true' if template.get('default_enabled', True) else 'false'
# Hidden burst templates represent their originating event; they must
# inherit both its category and its per-event toggle. Otherwise turning
# off an individual alert suppresses the first message but the hidden
# "+N more" summary still arrives later.
filter_event_type = event_type
if template.get('hidden', False):
source_event_type = str(data.get('event_type', '') or '')
if source_event_type in TEMPLATES:
filter_event_type = source_event_type
filter_template = TEMPLATES.get(filter_event_type, template)
event_group = filter_template.get('group', template.get('group', 'other'))
default_event_enabled = 'true' if filter_template.get('default_enabled', True) else 'false'
# Build AI config once (shared across channels, detail_level varies)
ai_config = self._build_ai_config()
@@ -1292,7 +1301,7 @@ class NotificationManager:
# ── Per-channel event check ──
# Default: from template default_enabled, unless explicitly set.
ch_event_key = f'{ch_name}.event.{event_type}'
ch_event_key = f'{ch_name}.event.{filter_event_type}'
if self._config.get(ch_event_key, default_event_enabled) == 'false':
continue # Channel has this specific event disabled
@@ -418,6 +418,73 @@ def _format_system_startup(data: Dict[str, Any]) -> Tuple[str, str]:
return title, body
def _format_app_update_available(data: Dict[str, Any]) -> Tuple[str, str]:
"""Render one app update or a scheduled multi-app summary."""
hostname = str(data.get("hostname") or _get_hostname())
updates = data.get("updates")
if not isinstance(updates, list) or len(updates) < 2:
app_name = str(data.get("app_name") or "app")
vmid = data.get("vmid", "")
ct_name = str(data.get("ct_name") or f"CT-{vmid}")
installed = str(data.get("installed") or "unknown")
latest = str(data.get("latest") or "unknown")
return (
f"{hostname}: {app_name} update available on CT {vmid}",
f"{app_name} on CT {vmid} ({ct_name}) has a new version:\n"
f" {installed}{latest}",
)
clean_updates = []
for item in updates:
if not isinstance(item, dict):
continue
try:
vmid = int(item.get("vmid"))
except (TypeError, ValueError):
continue
clean_updates.append({
"vmid": vmid,
"app_name": str(item.get("app_name") or "app"),
"installed": str(item.get("installed") or "unknown"),
"latest": str(item.get("latest") or "unknown"),
})
clean_updates.sort(
key=lambda item: (item["vmid"], item["app_name"].casefold())
)
if not clean_updates:
return (
f"{hostname}: Application updates available",
"Application updates are available.",
)
count = len(clean_updates)
container_count = len({item["vmid"] for item in clean_updates})
title = f"{hostname}: {count} application updates available"
lead = (
f"{count} applications in {container_count} LXC "
f"container{'s' if container_count != 1 else ''} have a newer version:"
)
sections = []
omitted = 0
for vmid in sorted({item["vmid"] for item in clean_updates}):
rows = [item for item in clean_updates if item["vmid"] == vmid]
section = [f"CT {vmid}"]
section.extend(
f"{item['app_name']}: {item['installed']}{item['latest']}"
for item in rows
)
candidate = "\n\n".join([lead, *sections, "\n".join(section)])
# Leave room for channel-specific wrappers and AI formatting while
# keeping the raw Telegram message comfortably below 4096 chars.
if len(candidate) > 3200:
omitted += len(rows)
continue
sections.append("\n".join(section))
if omitted:
sections.append(f"{omitted} additional application(s)")
return title, "\n\n".join([lead, *sections])
# ─── Severity Icons ──────────────────────────────────────────────
SEVERITY_ICONS = {
@@ -536,6 +603,7 @@ TEMPLATES = {
# this one off meant users who registered apps in the App tab
# never received the notification they explicitly asked for.
'default_enabled': True,
'formatter': '_format_app_update_available',
},
'docker_stack_update_available': {
'title': '{hostname}: Docker updates available on CT {vmid}',
@@ -968,6 +1036,13 @@ TEMPLATES = {
'group': 'services',
'default_enabled': True,
},
'kernel_warning': {
'title': '{hostname}: Kernel diagnostic event detected',
'body': 'The kernel recorded a diagnostic event.\n{kernel_details}',
'label': 'Kernel warnings and diagnostic traces',
'group': 'services',
'default_enabled': True,
},
'service_fail': {
'title': '{hostname}: Service failed — {service_name}',
'body': 'System service "{service_name}" has failed.\nReason: {reason}',
@@ -1811,6 +1886,7 @@ EVENT_EMOJI = {
'system_reboot': '\U0001F504',
'system_restore_completed': '', # check mark
'system_problem': '\u26A0\uFE0F',
'kernel_warning': '\u26A0\uFE0F',
'service_fail': '\u274C',
'oom_kill': '\U0001F4A3', # bomb
# Health
+25 -14
View File
@@ -1760,11 +1760,27 @@ def get_lynis_audit_status():
}
def parse_lynis_report():
def _parse_lynis_warning(value):
"""Lynis 3.x: ID|message|details|solution; retain legacy L/M/H records."""
parts = [part.strip() for part in value.split("|")]
if len(parts) < 2:
return None
legacy = parts[1] in ("L", "M", "H")
return {
"test_id": parts[0],
"severity": parts[1] if legacy else "",
"description": (parts[2] if len(parts) > 2 else "") if legacy else parts[1],
"details": "" if legacy or len(parts) < 3 or parts[2] == "-" else parts[2],
"solution": parts[3] if len(parts) > 3 and parts[3] != "-" else "",
}
def parse_lynis_report(enrich_current=True):
"""
Parse /var/log/lynis-report.dat into structured report data.
Also enriches with data from lynis.log when report.dat is sparse.
Returns a dict with all audit findings.
Returns a dict with all audit findings. Set enrich_current=False when
consuming historical evidence: do not run live fallback probes.
"""
report_file = "/var/log/lynis-report.dat"
output_file = "/var/log/lynis-output.log"
@@ -1890,14 +1906,9 @@ def parse_lynis_report():
# Parse warnings
for w in warnings_raw:
parts = w.split("|")
if len(parts) >= 2:
report["warnings"].append({
"test_id": parts[0].strip() if len(parts) > 0 else "",
"severity": parts[1].strip() if len(parts) > 1 else "",
"description": parts[2].strip() if len(parts) > 2 else parts[1].strip(),
"solution": parts[3].strip() if len(parts) > 3 else "",
})
warning = _parse_lynis_warning(w)
if warning:
report["warnings"].append(warning)
# Parse suggestions
for s in suggestions_raw:
@@ -2100,7 +2111,7 @@ def parse_lynis_report():
break
# Also check pve-firewall directly (Proxmox uses its own firewall service)
if not report["firewall_active"]:
if enrich_current and not report["firewall_active"]:
try:
rc, out, _ = _run_cmd(["systemctl", "is-active", "pve-firewall"])
if rc == 0 and out.strip() == "active":
@@ -2246,7 +2257,7 @@ def parse_lynis_report():
pass
# Fallback: get kernel from uname if still empty
if not report["kernel_version"]:
if enrich_current and not report["kernel_version"]:
try:
rc, out, _ = _run_cmd(["uname", "-r"])
if rc == 0 and out.strip():
@@ -2255,7 +2266,7 @@ def parse_lynis_report():
pass
# Fallback: get hostname from system
if not report["hostname"]:
if enrich_current and not report["hostname"]:
try:
import socket
report["hostname"] = socket.gethostname()
@@ -2263,7 +2274,7 @@ def parse_lynis_report():
pass
# Fallback: get installed packages count
if report["installed_packages"] == 0:
if enrich_current and report["installed_packages"] == 0:
try:
rc, out, _ = _run_cmd(["dpkg", "-l"])
if rc == 0 and out:
@@ -109,5 +109,105 @@ class SetupAuthTests(unittest.TestCase):
self.assertEqual(config[key], value)
class ChangePasswordTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
config_dir = Path(self.temp_dir.name)
config_patch = mock.patch.multiple(
auth_manager,
CONFIG_DIR=config_dir,
AUTH_CONFIG_FILE=config_dir / "auth.json",
)
config_patch.start()
self.addCleanup(config_patch.stop)
self.current_password = "CurrentPass1!"
self.new_password = "Replacement2!"
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps({
"enabled": True,
"configured": True,
"declined": False,
"username": "admin",
"password_hash": auth_manager.hash_password(self.current_password),
"totp_enabled": False,
"totp_secret": None,
"backup_codes": [],
}))
def read_config(self):
return json.loads(auth_manager.AUTH_CONFIG_FILE.read_text())
def test_missing_current_password_is_rejected_without_exception(self):
self.assertFalse(auth_manager.verify_password(None, self.read_config()["password_hash"]))
success, message = auth_manager.change_password(None, self.new_password)
self.assertFalse(success)
self.assertEqual(message, "Current password is incorrect")
def test_password_change_without_2fa(self):
success, message = auth_manager.change_password(
self.current_password, self.new_password
)
self.assertTrue(success, message)
self.assertTrue(auth_manager.verify_password(
self.new_password, self.read_config()["password_hash"]
))
def test_password_change_requires_2fa_when_enabled(self):
config = self.read_config()
config["totp_enabled"] = True
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config))
success, message = auth_manager.change_password(
self.current_password, self.new_password
)
self.assertFalse(success)
self.assertEqual(message, "2FA code required to change password")
self.assertTrue(auth_manager.verify_password(
self.current_password, self.read_config()["password_hash"]
))
def test_password_change_accepts_valid_2fa_code(self):
config = self.read_config()
config["totp_enabled"] = True
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config))
with mock.patch.object(
auth_manager, "verify_totp", return_value=(True, "accepted")
) as verify_totp:
success, message = auth_manager.change_password(
self.current_password, self.new_password, "123456"
)
self.assertTrue(success, message)
verify_totp.assert_called_once_with("admin", "123456", use_backup=False)
self.assertTrue(auth_manager.verify_password(
self.new_password, self.read_config()["password_hash"]
))
def test_password_change_rejects_invalid_2fa_and_preserves_password(self):
config = self.read_config()
config["totp_enabled"] = True
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config))
with mock.patch.object(
auth_manager, "verify_totp", return_value=(False, "rejected")
) as verify_totp:
success, message = auth_manager.change_password(
self.current_password, self.new_password, "000000"
)
self.assertFalse(success)
self.assertEqual(message, "Invalid 2FA code")
self.assertEqual(verify_totp.call_count, 2)
self.assertTrue(auth_manager.verify_password(
self.current_password, self.read_config()["password_hash"]
))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,129 @@
import sys
import unittest
from pathlib import Path
from queue import Queue
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
# These modules normally bind the live `/usr/local/share/proxmenux` database
# while importing. Ownership tests need no host state, so provide the same
# narrow dependency boundary used by the production functions below.
_health_persistence_module = ModuleType("health_persistence")
_health_persistence_module.health_persistence = SimpleNamespace(
cleanup_old_errors=lambda: None,
)
_health_persistence_module.disk_base_name = lambda name: str(name).replace("/dev/", "")
sys.modules.setdefault("health_persistence", _health_persistence_module)
sys.modules.setdefault("psutil", ModuleType("psutil"))
flask_server = SimpleNamespace(
get_proxmox_node_name=lambda: "fixture",
get_cached_pvesh_cluster_resources_vm=lambda: [],
get_cached_vm_disk=lambda _vmid: None,
)
sys.modules.setdefault("flask_server", flask_server)
import health_monitor # noqa: E402
import notification_events # noqa: E402
class _Persistence:
def __init__(self):
self.recorded = []
self.cleared = []
def record_error(self, **kwargs):
self.recorded.append(kwargs)
def get_active_errors(self, *args, **kwargs):
return []
def clear_error(self, key):
self.cleared.append(key)
class ClusterGuestStorageOwnershipTests(unittest.TestCase):
def setUp(self):
self.monitor = health_monitor.HealthMonitor.__new__(health_monitor.HealthMonitor)
self.persistence = _Persistence()
self.resources = [
{
"type": "lxc", "node": "hades", "status": "running",
"vmid": 128, "name": "plex", "disk": 94, "maxdisk": 100,
},
{
"type": "lxc", "node": "poseidon", "status": "running",
"vmid": 129, "name": "remote", "disk": 99, "maxdisk": 100,
},
]
def test_lxc_capacity_records_only_guests_owned_by_local_node(self):
with (
patch.object(health_monitor, "MOUNT_MONITOR_AVAILABLE", False),
patch.object(health_monitor, "health_persistence", self.persistence),
patch.object(flask_server, "get_proxmox_node_name", return_value="hades"),
patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=self.resources),
):
result = self.monitor._check_lxc_disk_usage()
self.assertEqual(result["status"], "WARNING")
self.assertEqual([row["error_key"] for row in self.persistence.recorded], ["lxc_disk_128"])
self.assertEqual(self.persistence.recorded[0]["details"]["node"], "hades")
self.assertNotIn("CT 129", result["checks"])
def test_vm_capacity_does_not_probe_remote_guest_agent(self):
resources = [
{"type": "qemu", "node": "hades", "status": "running", "vmid": 201, "name": "local"},
{"type": "qemu", "node": "poseidon", "status": "running", "vmid": 202, "name": "remote"},
]
def disk_for(vmid):
if vmid == 201:
return (94, 100)
raise AssertionError("remote VM was probed")
with (
patch.object(health_monitor, "health_persistence", self.persistence),
patch.object(flask_server, "get_proxmox_node_name", return_value="hades"),
patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources),
patch.object(flask_server, "get_cached_vm_disk", side_effect=disk_for),
):
result = self.monitor._check_vm_disk_usage()
self.assertEqual(result["status"], "WARNING")
self.assertEqual([row["error_key"] for row in self.persistence.recorded], ["vm_disk_201"])
self.assertEqual(self.persistence.recorded[0]["details"]["node"], "hades")
def test_foreign_legacy_record_is_not_a_recovery(self):
collector = notification_events.PollingCollector(Queue())
resources = [{"type": "lxc", "node": "poseidon", "vmid": 128}]
with (
patch.object(flask_server, "get_proxmox_node_name", return_value="hades"),
patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources),
):
foreign = collector._guest_storage_error_is_now_foreign(
"lxc_disk_128", {"details": {"vmid": "128"}}
)
self.assertTrue(foreign)
def test_local_recovery_remains_a_recovery(self):
collector = notification_events.PollingCollector(Queue())
resources = [{"type": "lxc", "node": "hades", "vmid": 128}]
with (
patch.object(flask_server, "get_proxmox_node_name", return_value="hades"),
patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources),
):
foreign = collector._guest_storage_error_is_now_foreign(
"lxc_disk_128", {"details": {"vmid": "128", "node": "hades"}}
)
self.assertFalse(foreign)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,86 @@
import json
import sys
import unittest
from pathlib import Path
from queue import Empty, Queue
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
APPIMAGE_DIR = SCRIPTS_DIR.parent
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import notification_events # noqa: E402
import notification_templates # noqa: E402
class KernelTraceNotificationTests(unittest.TestCase):
def setUp(self):
self.queue = Queue()
self.watcher = notification_events.JournalWatcher(self.queue)
def _check(self, message, *, syslog_id="kernel", transport="kernel"):
self.watcher._check_kernel_critical(
message,
syslog_id,
4,
{
"_TRANSPORT": transport,
"__REALTIME_TIMESTAMP": "1788883200000000",
},
)
def test_bare_call_trace_is_not_an_event(self):
self._check("Call Trace:")
with self.assertRaises(Empty):
self.queue.get_nowait()
def test_kernel_warning_carries_attributable_fields(self):
self._check(
"WARNING: CPU: 2 PID: 418 Comm: z_wr_iss at arc_evict_state+0x12/0x80"
)
event = self.queue.get_nowait()
self.assertEqual(event.event_type, "kernel_warning")
self.assertEqual(event.severity, "WARNING")
self.assertIn("Type: Kernel warning", event.data["kernel_details"])
self.assertIn("Process: z_wr_iss (PID 418)", event.data["kernel_details"])
self.assertIn("Component: arc_evict_state", event.data["kernel_details"])
self.assertIn("Recorded: 2026-", event.data["kernel_details"])
self.assertIn("WARNING: CPU", event.data["_journal_context"])
self._check("Call Trace:")
with self.assertRaises(Empty):
self.queue.get_nowait()
def test_application_text_cannot_impersonate_kernel_warning(self):
self._check(
"WARNING: CPU: 0 PID: 99 Comm: example at fake_function+0x1/0x2",
syslog_id="systemd",
transport="stdout",
)
with self.assertRaises(Empty):
self.queue.get_nowait()
def test_blocked_task_is_identified(self):
self._check("INFO: task txg_sync:812 blocked for more than 120 seconds.")
event = self.queue.get_nowait()
self.assertEqual(event.event_type, "kernel_warning")
self.assertIn("Type: Blocked kernel task", event.data["kernel_details"])
self.assertIn("Process: txg_sync", event.data["kernel_details"])
def test_event_is_visible_and_translated_in_every_monitor_locale(self):
services = notification_templates.get_event_types_by_group()["services"]
self.assertIn("kernel_warning", {item["type"] for item in services})
for locale in ("en", "es", "de", "fr", "it", "pt", "sk", "sv"):
messages = json.loads(
(APPIMAGE_DIR / "messages" / locale / "common.json").read_text(
encoding="utf-8"
)
)
self.assertTrue(
messages["settings"]["notifications"]["eventTypes"]["kernel_warning"]
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,140 @@
import json
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import lxc_apps
import notification_templates
def _app(app_id, name, installed, latest, **extra):
return {
"id": app_id,
"name": name,
"state": {
"installed_version": installed,
"latest_version": latest,
"update_available": True,
},
**extra,
}
class _FakeNotificationManager:
def __init__(self):
self.calls = []
def emit_event(self, **kwargs):
self.calls.append(kwargs)
return {"success": True}
class AppUpdateNotificationBatchTests(unittest.TestCase):
def _write_sidecar(self, directory, vmid, apps):
Path(directory, f"{vmid}.json").write_text(
json.dumps({"vmid": vmid, "apps": apps}),
encoding="utf-8",
)
def _emit(self, sidecars):
fake = _FakeNotificationManager()
module = types.SimpleNamespace(notification_manager=fake)
with tempfile.TemporaryDirectory() as directory:
for vmid, apps in sidecars.items():
self._write_sidecar(directory, vmid, apps)
with (
mock.patch.object(lxc_apps, "_APPS_DIR", directory),
mock.patch.dict(sys.modules, {"notification_manager": module}),
):
count = lxc_apps.emit_all_pending_updates()
return count, fake.calls
def test_multiple_updates_are_sent_as_one_sorted_batch(self):
count, calls = self._emit({
115: [
_app("redis", "Redis", "7.0.15-1", "8.10.1"),
_app("docmost", "Docmost", "0.23.2", "0.95.0"),
],
100: [_app("adguard", "AdGuard Home", "0.107.78", "0.107.79")],
})
self.assertEqual(count, 3)
self.assertEqual(len(calls), 1)
event = calls[0]
self.assertEqual(event["event_type"], "app_update_available")
self.assertEqual(event["entity"], "node")
self.assertTrue(event["entity_id"].startswith("batch:"))
self.assertEqual(event["data"]["count"], 3)
self.assertEqual(event["data"]["container_count"], 2)
self.assertEqual(
[(item["vmid"], item["app_name"]) for item in event["data"]["updates"]],
[(100, "AdGuard Home"), (115, "Docmost"), (115, "Redis")],
)
def test_single_update_keeps_the_individual_event_shape(self):
count, calls = self._emit({
101: [_app("npm", "Nginx Proxy Manager", "2.9.19", "2.15.1")],
})
self.assertEqual(count, 1)
self.assertEqual(len(calls), 1)
event = calls[0]
self.assertEqual(event["entity"], "ct")
self.assertNotIn("updates", event["data"])
self.assertEqual(event["data"]["vmid"], 101)
self.assertEqual(event["data"]["latest"], "2.15.1")
def test_batch_respects_opt_outs_and_docker_delegation(self):
count, calls = self._emit({
110: [
_app("silent", "Silent", "1.0", "2.0", notifications_enabled=False),
_app("docker", "Docker", "1.0", "2.0", helper_slug="docker"),
_app("portainer", "Portainer", "2.0", "2.1", update_via="docker"),
],
})
self.assertEqual(count, 0)
self.assertEqual(calls, [])
def test_check_all_can_refresh_without_emitting_individual_events(self):
sidecar = {"vmid": 120, "apps": [{"id": "one"}, {"id": "two"}]}
with (
mock.patch.object(lxc_apps, "_read_sidecar", return_value=sidecar),
mock.patch.object(lxc_apps, "check_app") as check,
):
lxc_apps.check_all(120, force=False, notify=False)
self.assertEqual(check.call_count, 2)
check.assert_any_call(120, "one", force=False, notify=False)
check.assert_any_call(120, "two", force=False, notify=False)
def test_batch_formatter_groups_versions_by_container(self):
rendered = notification_templates.render_template(
"app_update_available",
{
"hostname": "pve01",
"updates": [
{"vmid": 115, "app_name": "Redis", "installed": "7.0", "latest": "8.1"},
{"vmid": 100, "app_name": "AdGuard Home", "installed": "1.0", "latest": "1.1"},
{"vmid": 115, "app_name": "Docmost", "installed": "0.2", "latest": "0.9"},
],
},
)
self.assertEqual(rendered["title"], "pve01: 3 application updates available")
self.assertIn("3 applications in 2 LXC containers", rendered["body"])
self.assertLess(rendered["body"].index("CT 100"), rendered["body"].index("CT 115"))
self.assertIn("• Docmost: 0.2 → 0.9", rendered["body"])
self.assertIn("• Redis: 7.0 → 8.1", rendered["body"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,66 @@
import sys
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import notification_manager # noqa: E402
class RecordingChannel:
def __init__(self):
self.calls = 0
def send(self, title, body, severity, data):
self.calls += 1
return {"success": True, "error": ""}
class NotificationBurstToggleInheritanceTests(unittest.TestCase):
def setUp(self):
self.channel = RecordingChannel()
self.manager = notification_manager.NotificationManager()
self.manager._channels = {"email": self.channel}
self.manager._config = {
"email.enabled": "true",
"email.events.services": "true",
"email.rich_format": "false",
"email.event.kernel_warning": "false",
"ai_enabled": "false",
}
def test_hidden_summary_inherits_source_event_toggle(self):
delivered = self.manager._dispatch_to_channels(
"host: +1 more system problem",
"One additional issue",
"WARNING",
"burst_system",
{"event_type": "kernel_warning", "hostname": "host"},
"aggregator",
)
self.assertFalse(delivered)
self.assertEqual(self.channel.calls, 0)
def test_generic_summary_inherits_source_event_category(self):
self.manager._config.update({
"email.event.oom_kill": "true",
"email.events.services": "false",
"email.events.other": "true",
})
delivered = self.manager._dispatch_to_channels(
"host: related events",
"One additional issue",
"WARNING",
"burst_generic",
{"event_type": "oom_kill", "hostname": "host"},
"aggregator",
)
self.assertFalse(delivered)
self.assertEqual(self.channel.calls, 0)
if __name__ == "__main__":
unittest.main()