mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">→</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 }
|
||||
@@ -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(" / ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
/** 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: "✓", warn: "⚠", critical: "✗", info: "ⓘ" }[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
+1003
-37
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
|
||||
+3492
-161
File diff suppressed because it is too large
Load Diff
@@ -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 [])
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 ?"
|
||||
")",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user