Add audit and reports page, and a change journal

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-08 21:06:04 +02:00
co-authored by Claude Opus 5
parent ae75508eff
commit da8a480eff
102 changed files with 24118 additions and 1403 deletions
+965
View File
@@ -0,0 +1,965 @@
/**
* The Audit & Report document.
*
* Built on the shell the SMART, Lynis and latency reports share, so a
* reader who has seen one of those recognises this one: the same header
* and report identifier, the same numbered sections, the same action bar
* that disappears when the page is printed.
*
* What the document adds is structure. On screen findings are ordered by
* severity because the reader is triaging; on paper the node is
* described first — how it is built, what it connects to, what it holds
* — and only then judged, because a finding about a bridge means little
* to someone who has not been shown the bridge. The diagrams carry the
* relations the inventory resolves: a list of interfaces and a list of
* guests do not say which path a guest's traffic takes to the wire.
*
* The closing section states the scope: what the report covers and what
* it does not. That statement is what makes the document usable as
* evidence rather than a screenshot.
*/
import {
REPORT_CSS_AUDIT, callout, card, esc, grid, heading, openReportWindow,
renderReport, reportId, section, table, writeReport, icon,
} from "./report-shell"
import {
clusterDiagram, findingsChart, latencyChart, networkDiagram,
nodeArchitectureDiagram, storageDiagram,
} from "./report-diagrams"
import { parseEvidence } from "./evidence-format"
import { presentFinding, auditInstant, auditLabel, resultBreakdown, unreadSources, subscriptionLabel } from "./audit-presentation"
type Translate = (key: string, params?: Record<string, string>) => string
export interface DocumentInput {
profile: string
run: {
run_id: string; started_at: number; finished_at: number | null
// What the engine recorded about the declaration it judged against.
metadata?: { policy?: {
declared?: boolean; guests_declared?: number
storages_declared?: number; thresholds_declared?: string[]
} } | null
} | null
findings: Array<{
check_id: string; area: string; severity: string
classification: string; decision?: string
summary_key: string | null; summary_params: Record<string, unknown>
affected: Array<Record<string, unknown>>; evidence: string | null
incomplete?: boolean
sources?: Array<{ source: string; collected_at?: number; error?: string }>
exception?: { reason: string; accepted_by: string; accepted_at: number } | null
}>
inventory: any | null
t: Translate
locale: string
}
// One scale, worst first. An observation is drawn in a neutral blue
// rather than an alarm colour: it describes the host, it is not a fault.
const ORDER = ["critical", "warning", "observation", "unverified",
"accepted", "conformant", "not_applicable"]
const CLASS_COLOR: Record<string, string> = {
critical: "#dc2626", warning: "#ca8a04", observation: "#3b82f6",
unverified: "#94a3b8", accepted: "#4f46e5", conformant: "#16a34a",
not_applicable: "#cbd5e1",
}
/** What a finding reads as once the reader's decision is applied. */
function shownAs(f: { classification: string; decision?: string }): string {
return f.decision === "accepted" ? "accepted" : f.classification
}
function bytes(value: number | null | undefined): string {
if (!value || value <= 0) return "—"
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
let n = value, i = 0
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}`
}
/** Instants reach the document as epoch seconds or as an ISO string,
* depending on which store recorded them. */
function when(value: number | string | null | undefined, locale: string): string {
return auditInstant(value, locale)
}
function chip(state: string, label: string): string {
const mark = state === "critical" ? "×" : state === "warning" ? "!" : state === "conformant" ? "✓" : state === "observation" ? "ⓘ" : state === "unverified" ? "?" : "−"
return `<span class="chip ${esc(state)}"><span aria-hidden="true">${mark}</span> ${esc(label)}</span>`
}
function summaryOf(f: DocumentInput["findings"][number], t: Translate,
breakdown = false): string {
// The per-result breakdown belongs beside the table it describes. In
// the one-line findings summary it replaced the sentence with a bare
// count, which read as a broken cell next to every other row.
if (breakdown && f.check_id === "backup.last_backup_age" && f.affected.length) {
return resultBreakdown(f, t)
}
// A check that found nothing to apply to used to carry an English
// sentence written by the engine; it now says so in the reader's own.
if (!f.summary_key) {
return f.classification === "not_applicable" ? t("audit.notApplicableScope") : ""
}
const params: Record<string, string> = {}
for (const [k, v] of Object.entries(f.summary_params || {})) params[k] = String(v)
const key = `audit.checks.${f.check_id}.summary.${f.summary_key}`
const text = t(key, params)
return text === key ? t("audit.summaryFallback") : text
}
/**
* Evidence, rendered as the reader would want to read it rather than as
* the check happened to serialise it.
*/
function evidenceHtml(evidence: string | null, locale: string,
compact?: { t: Translate; rows?: number; lines?: number; blocks?: number }): string {
let blocks = parseEvidence(evidence, locale)
if (blocks.length === 0) return ""
let omitted = false
if (compact?.blocks && blocks.length > compact.blocks) {
blocks = blocks.slice(0, compact.blocks)
omitted = true
}
const parts = blocks.map((block) => {
const heading = block.title
? `<p class="evidence-title">${esc(block.title)}</p>` : ""
if (block.kind === "table") {
const rows = compact?.rows && block.rows.length > compact.rows
? (omitted = true, block.rows.slice(0, compact.rows)) : block.rows
if (block.columns.length > 6) {
return heading + rows.map(row => `<div class="evidence-record">` + table([], block.columns.map((column, i) => [esc(column), esc(row[i])])) + `</div>`).join("")
}
return heading + table(block.columns, rows.map((r) => r.map(esc)))
}
if (block.kind === "pairs") {
const entries = compact?.rows && block.entries.length > compact.rows
? (omitted = true, block.entries.slice(0, compact.rows)) : block.entries
return heading + table([], entries.map(([k, v]) =>
[`<span class="muted">${esc(k)}</span>`, esc(v)]))
}
const lines = compact?.lines && block.lines.length > compact.lines
? (omitted = true, block.lines.slice(0, compact.lines)) : block.lines
return heading + (lines.length
? `<ul class="evidence-list">${lines.map((l) =>
`<li>${esc(l)}</li>`).join("")}</ul>` : "")
})
const notice = omitted && compact
? `<p class="evidence-excerpt-note">${esc(auditLabel(compact.t, "evidenceExcerpt"))}</p>` : ""
return `<div class="evidence-block">${parts.join("")}${notice}</div>`
}
// ---------------------------------------------------------------------------
// Assessment summary
// ---------------------------------------------------------------------------
function executiveSummary(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
const counts: Record<string, number> = {}
for (const f of findings) {
const shown = shownAs(f)
counts[shown] = (counts[shown] || 0) + 1
}
const fails = counts.critical || 0
const warns = counts.warning || 0
// Coverage measures verified checks, not whether their result is favourable.
// Decisions/acceptances never turn missing evidence into verified evidence.
const applicable = findings.filter(f => f.classification !== "not_applicable")
const verifiedChecks = applicable.filter(f => !f.incomplete &&
["critical", "warning", "observation", "conformant", "accepted"].includes(f.classification))
const verified = verifiedChecks.length
const incomplete = verified < applicable.length || !!(input.run && !input.run.finished_at)
const coverage = applicable.length ? verified / applicable.length * 100 : 0
const coverageValue = applicable.length ? `${verified}/${applicable.length}` : "—"
const byArea: Record<string, Record<string, number>> = {}
for (const f of findings) {
const shown = shownAs(f)
byArea[f.area] = byArea[f.area] || {}
byArea[f.area][shown] = (byArea[f.area][shown] || 0) + 1
}
const chart = findingsChart(byArea, (a) => t(`audit.areas.${a}`), CLASS_COLOR, ORDER)
const legend = ORDER.filter((c) => counts[c]).map((s) =>
`<span style="display:inline-flex;align-items:center;gap:5px;margin-right:14px">
<span style="width:10px;height:10px;border-radius:2px;display:inline-block;
background:${CLASS_COLOR[s]}"></span>${esc(t(`audit.classifications.${s}`))}</span>`).join("")
const body = `
<div class="exec-box">
<div class="audit-verification-ring">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle cx="60" cy="60" r="54" fill="none" stroke="#e2e8f0" stroke-width="5" />
<circle cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="5"
pathLength="100" stroke-dasharray="${coverage} 100" transform="rotate(-90 60 60)" />
</svg>
<div class="audit-verification-value"><strong>${coverageValue}</strong>
<span>${esc(auditLabel(t, "verified"))}</span></div>
</div>
<div class="exec-text">
<h3 class="audit-result-heading">${icon("summary", 22, "#64748b")}${esc(t("audit.document.verdictHeading"))}</h3>
${!findings.length ? `<p>${esc(t("audit.document.verdictText.none"))}</p>` : !applicable.length ? `<p>${esc(auditLabel(t, "noApplicable"))}</p>` : ""}
${incomplete ? `<p class="assessment-incomplete">${esc(auditLabel(t, "incomplete"))}</p>` : ""}
<p class="muted">${esc(auditLabel(t, "verificationScope"))}</p>
<p style="font-size:11px;color:#64748b;margin-top:6px">
${esc(t("audit.document.runAt", { date: when(input.run?.started_at, locale) }))}
</p>
</div>
</div>
<div class="audit-counters">${[
card(t("audit.classifications.critical"), String(fails),
{ center: true, color: CLASS_COLOR.critical }),
card(t("audit.classifications.warning"), String(warns),
{ center: true, color: CLASS_COLOR.warning }),
card(t("audit.classifications.observation"), String(counts.observation || 0),
{ center: true, color: CLASS_COLOR.observation }),
card(t("audit.classifications.conformant"), String(counts.conformant || 0),
{ center: true, color: CLASS_COLOR.conformant }),
...["unverified", "accepted", "not_applicable"].filter(c => counts[c]).map(c => card(t(`audit.classifications.${c}`), String(counts[c]), {center: true, color: CLASS_COLOR[c]})),
].join("")}</div>
${chart ? `<div class="diagram" style="margin-top:14px">
<p class="diagram-note">${esc(t("audit.document.chartNote"))}</p>${chart}
<div style="margin-top:10px;font-size:10px;color:#475569">${legend}</div>
</div>` : ""}`
const overview = findings.filter(f => !["conformant", "not_applicable"].includes(shownAs(f))).sort((a,b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b)))
const listing = overview.length ? heading(auditLabel(t, "overview"), "findings") + table(
[auditLabel(t, "result"), t("audit.document.name"), auditLabel(t, "fact")],
overview.map(f => [chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
`<a href="#finding-${esc(f.check_id)}">${esc(t(`audit.checks.${f.check_id}.title`))}</a>`, esc(summaryOf(f,t))])) : ""
// The list and ring share the same records, so the numerator is auditable.
const checkList = (id: string, title: string, checks: DocumentInput["findings"]) => {
if (!checks.length) return ""
const sorted = [...checks].sort((a, b) =>
t(`audit.areas.${a.area}`).localeCompare(t(`audit.areas.${b.area}`), locale) ||
t(`audit.checks.${a.check_id}.title`).localeCompare(t(`audit.checks.${b.check_id}.title`), locale))
return `<div class="audit-checks-inventory" id="${id}">` +
heading(`${title} · ${checks.length}`, "summary") + table(
[auditLabel(t, "checkName"), t("audit.document.area"), auditLabel(t, "result")],
sorted.map(f => [
`<a href="#finding-${esc(f.check_id)}">${esc(t(`audit.checks.${f.check_id}.title`))}</a>`,
esc(t(`audit.areas.${f.area}`)), chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
])) + `</div>`
}
const checked = checkList("verified-checks", auditLabel(t, "verifiedChecks"), verifiedChecks)
const unverified = checkList("unverified-checks", auditLabel(t, "unverifiedChecks"),
applicable.filter(f => !verifiedChecks.includes(f)))
const notApplicable = checkList("not-applicable-checks", t("audit.classifications.not_applicable"),
findings.filter(f => f.classification === "not_applicable"))
return section(n, t("audit.document.executiveSummary"), body + checked + unverified + notApplicable + listing, "summary")
}
// ---------------------------------------------------------------------------
// Identity and cluster
// ---------------------------------------------------------------------------
function identitySection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const id = s.identity
const { t } = input
if (!id) return ""
const hw = s.hardware || {}
const body = grid(3, [
card(t("audit.inventory.node"), esc(id.node)),
card(t("audit.inventory.pveVersion"), esc(String(id.pve_version || "—").match(/pve-manager\/([^/]+)/)?.[1] || id.pve_version)),
card(t("audit.inventory.kernel"), esc(id.kernel)),
card(t("audit.inventory.subscription"), esc(subscriptionLabel(t, id.subscription))),
card(t("audit.inventory.cluster"), esc(id.cluster || t("audit.inventory.standalone"))),
card(t("audit.document.system"),
esc([hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ") || "—")),
])
return section(n, t("audit.document.nodeIdentity"), body, "node")
}
function clusterSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
if (!("cluster" in s)) return ""
const cluster = s.cluster
if (!cluster) {
return section(n, t("audit.document.cluster"),
callout("info", t("audit.inventory.standalone"),
esc(t("audit.document.standaloneNote"))), "cluster")
}
const diagram = clusterDiagram(cluster, {
thisNode: t("audit.document.thisNode"),
unreachable: t("audit.document.unreachable"),
links: t("audit.document.corosyncLinks"),
})
const rows = (cluster.nodes || []).map((node: any) => [
esc(node.name) + (node.local
? ` <span class="muted">(${esc(t("audit.document.thisNode"))})</span>` : ""),
esc(node.nodeid || "—"),
esc(node.ring0_addr || "—"),
esc(node.ring1_addr || "—"),
node.online === false
? chip("warn", t("audit.document.unreachable"))
: node.online === true ? chip("pass", t("audit.document.member")) : "—",
])
const body = `
${grid(3, [
card(t("audit.inventory.cluster"), esc(cluster.name)),
card(t("audit.document.quorum"), cluster.quorate == null
? "—" : chip(cluster.quorate ? "pass" : "fail",
t(cluster.quorate ? "audit.document.quorate" : "audit.document.inquorate"))),
card(t("audit.document.votes"),
esc(`${cluster.total_votes ?? "—"} / ${cluster.expected_votes ?? "—"}`)),
])}
${diagram ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.clusterDiagramNote"))}</p>${diagram}
</div>` : ""}
${table([t("audit.document.nodeName"), "nodeid", "ring0", "ring1",
t("audit.document.state")], rows)}`
return section(n, t("audit.document.cluster"), body, "cluster")
}
// ---------------------------------------------------------------------------
// How the node is built
// ---------------------------------------------------------------------------
function architectureSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const hw = s.hardware
const { t } = input
if (!hw) return ""
const cpu = hw.cpu || {}
const mem = hw.memory || {}
const diagram = nodeArchitectureDiagram(hw, s.identity || {}, {
chassis: t("audit.document.board"),
processor: t("audit.document.processor"),
memory: t("audit.document.memory"),
controllers: t("audit.document.controllers"),
disks: t("audit.document.disks"),
adapters: t("audit.document.adapters"),
slotsUsed: t("audit.document.slotsUsed"),
cores: t("audit.document.cores"),
threads: t("audit.document.threads"),
empty: t("audit.document.emptySlot"),
})
const identityRows = [
[t("audit.document.manufacturer"), esc(hw.system?.manufacturer || "—")],
[t("audit.document.product"), esc(hw.system?.product || "—")],
[t("audit.document.serial"), esc(hw.system?.serial || "—")],
[t("audit.document.board"),
esc([hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ") || "—")],
["BIOS", esc([hw.bios?.vendor, hw.bios?.version, hw.bios?.date]
.filter(Boolean).join(" · ") || "—")],
]
const memoryRows = (mem.modules || []).map((m: any) => [
esc(m.locator || "—"), esc(m.size || "—"), esc(m.type || "—"),
esc(m.form_factor || "—"), esc(m.speed || "—"),
esc([m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—"),
])
const controllerRows = (hw.controllers || []).map((c: any) => [
`<span class="muted" style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(c.slot)}</span>`,
esc(c.class), esc(c.name),
])
const body = `
${grid(4, [
card(t("audit.document.processor"), esc(cpu.model || "—")),
card(t("audit.document.topology"),
esc(`${cpu.sockets || 1} × ${cpu.cores_per_socket || "?"} / ${cpu.threads || "?"}`)),
card(t("audit.document.memory"), esc(bytes(hw.memory_bytes))),
card(t("audit.document.iommuGroups"), esc(String(hw.iommu_groups ?? "—"))),
])}
${diagram ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.architectureNote"))}</p>${diagram}
</div>` : ""}
${heading(t("audit.document.systemIdentity"), "node")}
${table([t("audit.document.field"), t("audit.document.value")], identityRows)}
${memoryRows.length ? `
${heading(t("audit.document.memoryModules"), "memory",
t("audit.document.slotsFilled", { used: String(mem.populated ?? 0),
total: String(mem.slots ?? mem.populated ?? 0) }))}
${table([t("audit.document.slot"), t("audit.document.size"), t("audit.document.type"),
t("audit.document.formFactor"), t("audit.document.speed"),
t("audit.document.manufacturer")], memoryRows)}` : ""}
${controllerRows.length ? `
${heading(t("audit.document.controllers"), "controller")}
${table(["PCI", t("audit.document.class"), t("audit.document.device")], controllerRows)}` : ""}`
return section(n, t("audit.document.architecture"), body, "architecture")
}
// ---------------------------------------------------------------------------
// Disks, with what has been observed of them
// ---------------------------------------------------------------------------
function disksSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const disks = s.hardware?.disks || []
const { t, locale } = input
if (!disks.length) return ""
const rows = disks.map((d: any) => {
const life = typeof d.power_on_hours === "number" && d.power_on_hours > 0
? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) })
: "—"
// smartctl reports the overall assessment as "PASSED"; the Monitor
// normalises some devices to "healthy".
const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase())
const health = ok ? chip("pass", t("audit.document.healthy"))
: d.health && d.health !== "unknown" ? chip("warn", esc(d.health)) : "—"
return [
`<strong>${esc(d.name)}</strong>`,
esc(d.model || "—"),
`<span class="muted" style="font-size:10.5px">${esc(d.serial || "—")}</span>`,
esc(bytes(d.size_bytes)),
esc(d.bus ? d.bus.toUpperCase() : "—") + (d.rotational ? " · HDD" : " · SSD"),
health,
esc(life),
d.observations?.length
? chip("warn", String(d.observations.length))
: `<span class="muted">—</span>`,
]
})
// Observations are the disk's history. SMART reports what is true now;
// the log reports what happened. A disk that recovered still recorded
// the event, and that pattern is what precedes a failure.
const withEvents = disks.filter((d: any) => (d.observations || []).length)
const observations = withEvents.map((d: any) => {
const entries = d.observations.map((o: any) => [
esc(o.type || "—"),
// The stored severity is an English database value, and this page
// exists in eight languages.
o.severity === "critical"
? chip("fail", esc(t("audit.classifications.critical")))
: o.severity ? chip("warn", esc(t("audit.classifications.warning"))) : "—",
esc(String(o.count ?? "—")),
esc(when(o.first_seen, locale)),
esc(when(o.last_seen, locale)),
`<span class="muted" style="font-size:10.5px">${esc(o.message || "")}</span>`,
])
return `${heading(d.name, "disks", d.model || undefined)}
${table([t("audit.document.event"), t("audit.document.severity"),
t("audit.document.occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), t("audit.document.detail")], entries)}`
}).join("")
const body = `
${table([t("audit.document.device"), t("audit.document.model"),
t("audit.document.serial"), t("audit.document.size"),
t("audit.document.bus"), "SMART", t("audit.document.serviceLife"),
t("audit.document.events")], rows)}
${heading(t("audit.document.observations"), "observation")}
${withEvents.length
? `<p class="diagram-note">${esc(t("audit.document.observationsNote"))}</p>${observations}`
: callout("ok", t("audit.document.noObservations"),
esc(t("audit.document.noObservationsNote")))}`
return section(n, t("audit.document.storageDevices"), body, "disks")
}
// ---------------------------------------------------------------------------
// Network
// ---------------------------------------------------------------------------
function chain(hops: Array<{ id: string; mode?: string }> | null, t: Translate): string {
if (hops === null) return `<span class="muted">${esc(t("audit.inventory.unresolved"))}</span>`
if (hops.length === 0) return `<span class="muted">${esc(t("audit.inventory.noUplink"))}</span>`
return hops.map((h) => esc(h.id + (h.mode ? ` · ${h.mode}` : "")))
.join('<span class="sep">&rarr;</span>')
}
function networkSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
const net = s.network
const guests = s.guests || []
const adapters = s.hardware?.adapters || []
if (!net && !adapters.length) return ""
const diagram = net?.bridges
? networkDiagram(net.bridges, guests, {
nic: t("audit.document.adapters"), bond: t("audit.document.bond"),
bridge: t("audit.document.bridge"), guests: t("audit.inventory.guests"),
})
: ""
const adapterRows = adapters.map((a: any) => [
`<strong>${esc(a.name)}</strong>`,
a.state === "up" ? chip("pass", esc(a.state)) : chip("unknown", esc(a.state || "—")),
a.speed_mbps
? esc(a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`)
: "—",
`<span class="muted" style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(a.mac || "—")}</span>`,
esc(a.driver || "—"),
`<span class="muted" style="font-size:10.5px">${esc(a.pci || "—")}</span>`,
])
const bridgeRows = Object.entries(net?.bridges || {}).map(([id, b]: [string, any]) => [
`<strong>${esc(id)}</strong>`,
chain(b.uplink ?? null, t),
esc(String(guests.filter((g: any) =>
(g.interfaces || []).some((i: any) => i.bridge === id)).length)),
])
const body = `
${diagram ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.networkDiagramNote"))}</p>${diagram}
</div>` : ""}
${adapterRows.length ? `
${heading(t("audit.document.physicalAdapters"), "adapter")}
${table([t("audit.document.interface"), t("audit.document.state"),
t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"],
adapterRows)}` : ""}
${bridgeRows.length ? `
${heading(t("audit.document.bridges"), "bridge")}
${table([t("audit.document.bridge"), t("audit.document.uplink"),
t("audit.inventory.guests")], bridgeRows)}` : ""}`
return section(n, t("audit.document.network"), body, "network")
}
function latencySection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
const latency = s.latency
if (!latency?.targets?.length) return ""
// The legend reads in the reader's language, like the table under it.
const named = latency.targets.map((target: any) => ({
...target, label: t(`audit.document.target.${target.target}`),
}))
const chart = latencyChart(named, {
ms: t("audit.document.milliseconds"), hours: t("audit.document.hours"),
})
const ms = (v: number | null | undefined) =>
typeof v === "number" ? `${v} ms` : "—"
const rows = latency.targets.map((target: any) => [
`<strong>${esc(t(`audit.document.target.${target.target}`))}</strong>`,
esc(ms(target.min_ms)), esc(ms(target.avg_ms)), esc(ms(target.max_ms)),
esc(typeof target.packet_loss === "number" ? `${target.packet_loss} %` : "—"),
esc(String(target.samples)),
])
const body = `
${chart ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.latencyNote"))}</p>${chart}
</div>` : ""}
${table([t("audit.document.target.label"), t("audit.document.minimum"),
t("audit.document.average"), t("audit.document.maximum"),
t("audit.document.packetLoss"), t("audit.document.samples")], rows)}`
return section(n, t("audit.document.latency"), body, "latency")
}
// ---------------------------------------------------------------------------
// Storage and protection
// ---------------------------------------------------------------------------
function storageSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
const storages = s.storages || []
const guests = s.guests || []
if (!storages.length) return ""
const diagram = storageDiagram(guests, {
guests: t("audit.inventory.guests"), storage: t("audit.document.storage"),
backup: t("audit.document.backupDestination"),
unprotected: auditLabel(t,"noJob"),
})
const rows = storages.map((st: any) => [
`<strong>${esc(st.id)}</strong>`,
esc(st.type),
esc(st.content || "—"),
st.shared ? chip("pass", t("audit.document.shared")) : `<span class="muted">—</span>`,
esc(st.server || st.path || "—"),
esc(String(guests.filter((g: any) =>
(g.disks || []).some((d: any) => d.storage === st.id)).length)),
])
const unprotected = guests.filter((g: any) => !(g.backups || []).length)
const selected = guests.length - unprotected.length
const fraction = guests.length ? selected / guests.length * 100 : 0
const capacityFinding = input.findings.find(f => f.check_id === "storage.connected_storage")
let capacityRows: any[] = []
try { capacityRows = JSON.parse(capacityFinding?.evidence || "{}").storages || [] } catch { /* Raw evidence stays in the appendix. */ }
const capacity = capacityRows.filter(r => Number(r.total) > 0 && r.used != null).map(r => {
const ratio = Math.max(0, Math.min(100, Number(r.used) / Number(r.total) * 100))
return `<div class="capacity-item"><strong>${esc(r.storage)}</strong><span>${esc(bytes(Number(r.used)))} / ${esc(bytes(Number(r.total)))}</span><div class="audit-meter"><span style="width:${ratio}%"></span></div></div>`
}).join("")
const body = `
${guests.length ? `<div class="coverage-panel"><h3>${icon("storage")}${esc(auditLabel(t,"coverage"))}</h3>
<div class="audit-meter"><span style="width:${fraction}%"></span></div>
<div class="coverage-labels"><span>${selected} / ${guests.length} · ${esc(auditLabel(t,"scheduled"))}</span><span>${unprotected.length} · ${esc(auditLabel(t,"noJob"))}</span></div>
<p class="muted">${esc(auditLabel(t,"copyScope"))}</p></div>` : ""}
${diagram ? `<div class="diagram">
<p class="diagram-note">${esc(t("audit.document.storageDiagramNote"))}</p>${diagram}
</div>` : ""}
${table([t("audit.document.storage"), t("audit.document.type"),
t("audit.document.content"), t("audit.document.shared"),
t("audit.document.location"), t("audit.inventory.guests")], rows)}
${capacity ? heading(auditLabel(t,"capacity"), "storage") + capacity : ""}
${unprotected.length
? callout("info", t("audit.document.unprotectedGuests",
{ count: String(unprotected.length) }),
esc(unprotected.map((g: any) => `${g.vmid} ${g.name}`).join(" · ")))
: callout("info", auditLabel(t,"scheduled"),
esc(auditLabel(t,"copyScope")))}`
return section(n, t("audit.document.storageAndProtection"), body, "storage")
}
// ---------------------------------------------------------------------------
// Guests, passthrough, managed software
// ---------------------------------------------------------------------------
function guestsSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const guests = s.guests || []
const { t } = input
if (!guests.length) return ""
const rows = guests.map((g: any) => [
`<strong>${esc(String(g.vmid))}</strong>`,
esc(g.name || "—"),
g.type === "lxc" ? "LXC" : "VM",
esc(String(g.cores || "—")),
esc(g.memory ? bytes(Number(g.memory) * 1024 * 1024) : "—"),
esc([...new Set((g.disks || []).map((d: any) => d.storage).filter(Boolean))].join(", ") || "—"),
esc([...new Set((g.interfaces || []).map((i: any) => i.bridge).filter(Boolean))].join(", ") || "—"),
(g.backups || []).length
? esc((g.backups || []).map((b: any) => b.storage).join(", "))
: esc(auditLabel(t,"noJob")),
])
return section(n, t("audit.inventory.guests"),
table([t("audit.document.vmid"), t("audit.document.name"), t("audit.document.kind"),
t("audit.document.cores"), t("audit.document.memory"),
t("audit.document.storage"), t("audit.document.bridge"),
t("audit.document.backup")], rows), "guests")
}
function passthroughSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const devices = s.passthrough || []
const { t } = input
if (!devices.length) return ""
const rows = devices.map((d: any) => [
esc(String(d.vmid)),
esc(d.guest || "—"),
esc(d.slot || "—"),
`<span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(d.address || "—")}</span>`,
esc((d.iommu_groups || []).join(", ") || "—"),
(d.shared_group_devices || []).length
? chip("warn", String(d.shared_group_devices.length))
: `<span class="muted">—</span>`,
])
return section(n, t("audit.inventory.passthrough"),
table([t("audit.document.vmid"), t("audit.document.name"), t("audit.document.slot"),
t("audit.document.device"), t("audit.document.iommuGroup"),
auditLabel(t,"otherDevices")], rows), "passthrough")
}
function proxmenuxSection(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const { t } = input
const pmx = s.proxmenux
const apps = s.applications || []
if (!pmx && !apps.length) return ""
const toolRows = (pmx?.optimizations || []).map((tool: any) => {
const pending = (pmx?.pending_updates || []).find((u: any) => u.key === tool.key)
return [
esc(tool.key.replace(/_/g, " ")), esc(tool.version === "True" || tool.version === "False" ? auditLabel(t,"unversioned") : tool.version || auditLabel(t,"unversioned")),
pending ? chip("warn", t("audit.document.updateAvailable",
{ version: String(pending.available) }))
: esc(auditLabel(t,"noPendingRecorded")),
]
})
const appRows = apps.map((a: any) => [
esc(a.name || "—"), esc(String(a.vmid ?? "—")),
esc(a.version || t("audit.inventory.versionUnknown")),
])
const body = `
${toolRows.length ? `
${heading(t("audit.inventory.proxmenux"), "software")}
${table([t("audit.document.name"), t("audit.document.version"),
t("audit.document.state")], toolRows)}` : ""}
${appRows.length ? `
${heading(t("audit.inventory.applications"), "software")}
${table([t("audit.document.name"), t("audit.document.vmid"),
t("audit.document.version")], appRows)}` : ""}`
return section(n, t("audit.document.managedSoftware"), body, "software")
}
// ---------------------------------------------------------------------------
// Findings in full
// ---------------------------------------------------------------------------
function findingsSection(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
if (findings.length === 0) return ""
const areas = Array.from(new Set(findings.map((f) => f.area))).sort()
const parts: string[] = []
for (const area of areas) {
// Within an area the reader still wants the worst first.
const rows = findings.filter((f) => f.area === area)
.sort((a, b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b)))
parts.push(`${heading(t(`audit.areas.${area}`))}`)
for (const f of rows) {
const groups = presentFinding(f, t, locale, input.inventory?.sections?.guests || [])
const bits = [
`<div class="finding-head" id="finding-${esc(f.check_id)}">`,
chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
`<span class="title">${esc(t(`audit.checks.${f.check_id}.title`))}</span>`,
f.incomplete ? chip("unknown", t("audit.document.incomplete")) : "",
`</div>`,
]
const summary = summaryOf(f, t, true)
if (summary) bits.push(`<p>${esc(summary)}</p>`)
// "Could not be evaluated" describes the assessment, not the host.
const unread = unreadSources(f.sources, t)
if (unread) bits.push(`<p class="muted">${esc(unread)}</p>`)
bits.push(`<p class="rationale">${esc(t(`audit.checks.${f.check_id}.rationale`))}</p>`)
if (f.exception) {
bits.push(`<p><strong>${esc(t("audit.detail.acceptedRisk"))}:</strong> ` +
`${esc(f.exception.reason)} — ${esc(f.exception.accepted_by)}, ` +
`${esc(when(f.exception.accepted_at, locale))}</p>`)
}
for (const group of groups) {
bits.push(heading(group.title), group.note ? `<p class="muted">${esc(group.note)}</p>` : "", table(group.columns, group.rows.map(row => row.cells.map(esc))))
}
if (f.evidence && shownAs(f) === "conformant" && groups.length === 0) {
bits.push(heading(auditLabel(t, "evidenceObserved"), "scope"),
evidenceHtml(f.evidence, locale, { t, rows: 4, lines: 5, blocks: 3 }))
} else if (f.evidence && f.classification !== "not_applicable") {
bits.push(`<p class="technical-ref"><a href="#evidence-${esc(f.check_id)}">${esc(auditLabel(t,"detailsLink"))}: ${esc(f.check_id)}</a></p>`)
}
const rowCount = groups.reduce((total, group) => total + group.rows.length, 0)
parts.push(`<div class="finding ${esc(shownAs(f))} ${rowCount <= 4 ? "finding-short" : "finding-long"}">${bits.join("\n")}</div>`)
}
}
return section(n, t("audit.document.findings"), parts.join("\n"), "findings")
}
// ---------------------------------------------------------------------------
// Quick diagnosis
// ---------------------------------------------------------------------------
/** How many affected rows a diagnostic prints before it stops counting. */
const DIAGNOSTIC_ROW_CAP = 8
/**
* What the host is asking its administrator to decide, and nothing else.
*
* The full report answers "what is this machine"; this one answers "what
* do I do now". Everything conformant is left out on purpose: a document
* that prints thirty passing checks to reach five failing ones makes the
* five harder to find, which is the opposite of a diagnosis.
*/
function diagnosticSummary(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
const acting = findings.filter((f) => ["critical", "warning"].includes(shownAs(f)))
const counts = ["critical", "warning"].map((c) => ({
key: c, total: findings.filter((f) => shownAs(f) === c).length,
}))
const node = input.inventory?.sections?.identity?.node || t("audit.document.unknownNode")
const ran = input.run?.finished_at ?? input.run?.started_at
const body = grid(4, [
card(t("audit.document.node"), esc(String(node))),
card(t("audit.document.generated"), esc(when(ran, locale))),
...counts.map((c) => card(t(`audit.classifications.${c.key}`), String(c.total))),
])
const verdict = acting.length
? `<p>${esc(t("audit.document.diagnosticActing", { count: String(acting.length) }))}</p>`
: `<p>${esc(t("audit.document.diagnosticClear"))}</p>`
return section(n, t("audit.document.diagnosticTitle"), body + verdict, "summary")
}
/**
* Each finding that asks for a decision, with the evidence needed to
* take it and no more. Long tables are cut: thirty identical rows say
* the same thing the first eight already said, and the reader who wants
* every one of them wants the full report.
*/
function actionsSection(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
const acting = findings
.filter((f) => ["critical", "warning"].includes(shownAs(f)))
.sort((a, b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b)))
if (!acting.length) return ""
const parts = acting.map((f) => {
const bits = [
`<div class="finding-head" id="finding-${esc(f.check_id)}">`,
chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
`<span class="title">${esc(t(`audit.checks.${f.check_id}.title`))}</span>`,
`<span class="muted">${esc(t(`audit.areas.${f.area}`))}</span>`,
`</div>`,
]
const summary = summaryOf(f, t)
if (summary) bits.push(`<p>${esc(summary)}</p>`)
const unread = unreadSources(f.sources, t)
if (unread) bits.push(`<p class="muted">${esc(unread)}</p>`)
bits.push(`<p class="rationale">${esc(t(`audit.checks.${f.check_id}.rationale`))}</p>`)
for (const group of presentFinding(f, t, locale, input.inventory?.sections?.guests || [])) {
const shown = group.rows.slice(0, DIAGNOSTIC_ROW_CAP)
bits.push(heading(group.title),
table(group.columns, shown.map((row) => row.cells.map(esc))))
if (group.rows.length > shown.length) {
bits.push(`<p class="muted">${esc(t("audit.document.diagnosticMoreRows",
{ count: String(group.rows.length - shown.length) }))}</p>`)
}
}
return `<div class="finding ${esc(shownAs(f))}">${bits.join("\n")}</div>`
})
// The same heading the full report uses: naming the section after what
// the reader is expected to do with it was a judgement the document
// has no business making.
return section(n, t("audit.document.findings"), parts.join("\n"), "findings")
}
/**
* Readings that could not be taken. Kept because a diagnosis that hides
* its own blind spots is worse than one that names them.
*/
function unreadSection(input: DocumentInput, n: number): string {
const { findings, t } = input
const unread = findings.filter((f) => f.classification === "unverified")
if (!unread.length) return ""
const rows = unread.map((f) => [
esc(t(`audit.checks.${f.check_id}.title`)),
esc(t(`audit.areas.${f.area}`)),
esc(summaryOf(f, t)),
])
return section(n, t("audit.document.diagnosticUnread"),
table([t("audit.presentation.checkName"), t("audit.document.area"),
auditLabel(t, "fact")], rows), "scope")
}
// ---------------------------------------------------------------------------
// Scope
// ---------------------------------------------------------------------------
function evidenceSection(input: DocumentInput, n: number): string {
// Passing checks carry a compact evidence excerpt beside their result.
// The appendix is reserved for findings whose evidence an operator may
// need to investigate, which keeps a useful report from becoming dozens
// of pages of successful raw probes.
const rows = input.findings.filter(f => f.evidence &&
!["conformant", "not_applicable"].includes(shownAs(f)))
if (!rows.length) return ""
return section(n, auditLabel(input.t,"annex"), `<p class="muted">${esc(auditLabel(input.t,"annexScope"))}</p>` + rows.map(f =>
`<div class="technical-entry" id="evidence-${esc(f.check_id)}">` + heading(input.t(`audit.checks.${f.check_id}.title`), "scope", f.check_id) +
evidenceHtml(f.evidence, input.locale) + `</div>`).join(""), "scope")
}
function scopeSection(input: DocumentInput, n: number): string {
const { t, inventory } = input
const missing = Object.entries(inventory?.unavailable || {})
// The engine records which declaration it judged against. A report
// that omits it reads identically whether the host was measured
// against stated expectations or against none, and those are two
// different reports about the same machine.
const policy = input.run?.metadata?.policy
const declared = policy?.declared
? t("audit.document.policyDeclared", {
guests: String(policy.guests_declared ?? 0),
storages: String(policy.storages_declared ?? 0),
thresholds: String((policy.thresholds_declared || []).length),
})
: t("audit.document.policyNone")
const body = `
<div class="scope">
<p style="margin:0">${esc(t("audit.document.scopeText",
{ profile: t(`audit.profile.${input.profile}`) }))}</p>
<ul>
<li>${esc(t("audit.document.scopeLocal"))}</li>
<li>${esc(auditLabel(t,"readOnlyScope"))}</li>
<li>${esc(t("audit.document.scopeMoment"))}</li>
<li>${esc(declared)}</li>
</ul>
${missing.length ? `
<p style="margin:12px 0 4px"><strong>${esc(t("audit.document.notRead"))}</strong></p>
<ul>${missing.map(([k, v]) =>
`<li>${esc(k)}: ${esc(String(v))}</li>`).join("")}</ul>` : ""}
</div>`
return section(n, t("audit.document.scope"), body, "scope")
}
// ---------------------------------------------------------------------------
export function buildAuditDocument(input: DocumentInput): string {
const { t, locale } = input
const node = input.inventory?.sections?.identity?.node || t("audit.document.unknownNode")
const id = reportId("AUDIT")
// The quick diagnosis is a different document, not the same one with
// sections withheld: it opens on what needs a decision instead of on
// what the machine is, and it prints no inventory, no diagrams and no
// annex. Everything is still assessed — only the printing is short.
// Structure and configuration, with nothing assessed. The profile runs
// no checks, so an assessment summary above it counted nothing and a
// findings section below it listed nothing: two empty frames around
// the only thing the reader opened this for.
const builders = input.profile === "inventory"
? [
identitySection, clusterSection, architectureSection, disksSection,
networkSection, latencySection, storageSection, guestsSection,
passthroughSection, proxmenuxSection, scopeSection,
]
: input.profile === "diagnostic"
? [diagnosticSummary, actionsSection, unreadSection, scopeSection]
: [
executiveSummary, identitySection, clusterSection, architectureSection,
disksSection, networkSection, latencySection, storageSection, guestsSection,
passthroughSection, proxmenuxSection, findingsSection, scopeSection, evidenceSection,
]
// A section a profile did not ask for produces nothing, and the
// numbering closes over the gap rather than skipping a number. Each
// builder is therefore called once the previous one is known to have
// produced something, not in a pass of its own.
const sections: string[] = []
for (const build of builders) {
const html = build(input, sections.length + 1)
if (html) sections.push(html)
}
const body = sections.join("\n").replace(/<table\b/g, '<div class="audit-table-scroll"><table').replace(/<\/table>/g, '</table></div>')
// A document that assesses nothing should not be titled as an audit.
const documentKey = input.profile === "diagnostic" ? "diagnostic"
: input.profile === "inventory" ? "structure" : ""
return renderReport({
title: documentKey ? t(`audit.document.${documentKey}Title`) : t("audit.document.title"),
subtitle: documentKey ? t(`audit.document.${documentKey}Subtitle`, { node })
: t("audit.document.subtitle", { node }),
topBarSubtitle: node,
meta: [
[t("audit.document.node"), node],
[t("audit.document.profile"), t(`audit.profile.${input.profile}`)],
[t("audit.document.generated"), new Date().toLocaleString(locale)],
],
reportId: id,
logoUrl: `${window.location.origin}/images/proxmenux-logo.png`,
footerLeft: `ProxMenux · ${t("audit.document.title")} · ${node}`,
footerRight: `${id} · ${new Date().toLocaleDateString(locale)}`,
lang: locale,
extraCss: REPORT_CSS_AUDIT + `@page { @bottom-left { content: "ProxMenux · ${esc(String(node)).replace(/["\\\n\r]/g, " ")}"; font-size: 8pt; color: #64748b; } @bottom-right { content: counter(page) " / " counter(pages); font-size: 8pt; color: #64748b; } }`,
body,
})
}
/**
* The window is opened by the caller on the click itself so the popup
* blocker sees the gesture; the document is written into it once the
* inventory has been fetched.
*/
export function openAuditDocument(input: DocumentInput, target: Window | null): void {
writeReport(target, buildAuditDocument(input))
}
export { openReportWindow }
+325
View File
@@ -0,0 +1,325 @@
/** Descriptive view model shared by the Monitor and the printable report.
* Raw evidence remains separate. This layer never proposes an action.
*/
import { splitLeadingJson, durationOf, formatValue } from "./evidence-format"
export type AuditTranslate = (key: string, params?: Record<string, string>) => string
export interface PresentedFinding {
check_id: string
classification: string
affected: Array<Record<string, unknown>>
evidence: string | null
}
export interface AuditGroup {
title: string
note?: string
columns: string[]
rows: Array<{ cells: string[]; classification: string }>
}
export const auditLabel = (t: AuditTranslate, key: string) => t(`audit.presentation.${key}`)
/** El estado que devuelve `pvesubscription get`, en palabras del lector. */
export function subscriptionLabel(t: AuditTranslate, status?: string | null): string {
const key = (status || "").trim().toLowerCase()
if (!key) return ""
const known = ["notfound", "active", "invalid", "expired", "suspended", "new", "unknown"]
return known.includes(key) ? t(`audit.inventory.subscriptionStatus.${key}`) : (status as string)
}
/**
* What a check could not read, in the reader's own words.
*
* A check that reports "could not be evaluated" and stops there
* describes the assessment rather than the host: the reason is recorded
* against each source, but it sat two collapsed sections below a line
* that explained nothing. This is what the finding says out loud
* instead.
*/
export function unreadSources(
sources: Array<{ source: string; error?: string }> | undefined,
t: AuditTranslate,
): string {
const failed = (sources || []).filter((s) => s.error)
if (!failed.length) return ""
const named = failed.map((s) => {
// Sources are recorded as they were invoked — `cmd:["pvesm", …]`.
// The reader wants the command, not its serialisation.
let name = s.source
if (name.startsWith("cmd:")) {
try {
name = (JSON.parse(name.slice(4)) as string[]).join(" ")
} catch {
name = name.slice(4)
}
}
return `${name} — ${String(s.error).replace(/\s+/g, " ").trim()}`
})
return `${auditLabel(t, "couldNotRead")}: ${named.join(" · ")}`
}
export function auditDuration(hours: number, locale: string): string {
return durationOf(hours, locale)
}
export function evidenceRecords(evidence: string | null): Array<Record<string, any>> {
const parsed = splitLeadingJson((evidence || "").trim())
return parsed && Array.isArray(parsed[0]) ? parsed[0].filter(x => x && typeof x === "object") : []
}
const clean = (v: unknown): string => v === undefined || v === null || v === "-" ? "" : String(v)
/**
* Instants in the audit have two deliberate forms: epoch seconds from
* Proxmox, and local ISO timestamps from the Monitor's SQLite stores.
* A timezone-less SQLite value is already local wall time; treating it
* as UTC shifts it a second time in the printable report.
*/
function auditDate(value: unknown): Date | null {
if (value === undefined || value === null || value === "") return null
if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value))) {
const date = new Date(Number(value) * 1000)
return Number.isNaN(date.getTime()) ? null : date
}
const text = String(value).trim()
const local = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/.exec(text)
const date = local
? new Date(Number(local[1]), Number(local[2]) - 1, Number(local[3]),
Number(local[4]), Number(local[5]), Number(local[6]),
Number((local[7] || "0").slice(0, 3).padEnd(3, "0")))
: new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
export function auditInstant(value: unknown, locale: string): string {
const date = auditDate(value)
return date ? date.toLocaleString(locale) : clean(value) || "—"
}
export function presentFinding(f: PresentedFinding, t: AuditTranslate, locale: string,
guests: Array<{ vmid: number; name?: string; type?: string }> = []): AuditGroup[] {
const label = (key: string) => auditLabel(t, key)
const records = evidenceRecords(f.evidence)
const parsedEvidence = splitLeadingJson((f.evidence || "").trim())
const evidenceObject = parsedEvidence && parsedEvidence[0] &&
typeof parsedEvidence[0] === "object" && !Array.isArray(parsedEvidence[0])
? parsedEvidence[0] as Record<string, any> : null
const guest = (o: Record<string, unknown>) => {
const id = o.vmid ?? o.guest
const known = guests.find(g => String(g.vmid) === String(id))
const type = o.type || known?.type
const prefix = type === "qemu" || type === "vm" ? "VM" : type === "lxc" || type === "ct" ? "LXC" : label("guest")
const name = clean(o.name || known?.name)
return `${name ? name + " · " : ""}${prefix} ${id}`
}
const resource = (o: Record<string, unknown>) => o.vmid !== undefined || o.guest !== undefined
? guest(o) : clean(o.name || o.device || o.storage || o.pool || o.bond || o.interface || o.job || o.package || o.test) || label("host")
// A decision the reader took stands in front of the technical result:
// an object excluded by policy is not a finding at a low gravity, it
// is one that was taken out of the question.
const status = (o: Record<string, unknown>) =>
clean(o.decision) || clean(o.classification) || f.classification
const state = (o: Record<string, unknown>) => t(`audit.classifications.${status(o)}`)
const reason = (o: Record<string, unknown>) => {
const key = `audit.presentation.reasons.${clean(o.reason_key)}`
const translated = t(key)
return translated !== key ? translated : t(`audit.checks.${f.check_id}.title`)
}
const group = (title: string, columns: string[], objects: Array<Record<string, unknown>>,
cells: (o: Record<string, unknown>) => string[]): AuditGroup => ({
title, columns, rows: objects.map(o => ({ cells: cells(o), classification: status(o) })),
})
if (f.check_id === "storage.connected_storage") {
const storages = Array.isArray(evidenceObject?.storages)
? evidenceObject!.storages.filter((o: unknown) => o && typeof o === "object") : []
if (storages.length) {
const objects = storages.map((row: Record<string, unknown>) => {
const finding = f.affected.find(o => o.storage === row.storage)
return { ...row, classification: finding?.classification ||
(["active", "available", "namespace_restricted"].includes(clean(row.status))
? "conformant" : "unverified") }
})
return [group("PVE", [t("audit.document.storage"), t("audit.document.type"),
t("audit.document.state"), label("capacity"), label("fact")], objects, o => {
const dependencyCount = Array.isArray(o.dependencies) ? o.dependencies.length : 0
const jobCount = Array.isArray(o.jobs) ? o.jobs.length : 0
const observed = [dependencyCount ? `${dependencyCount} ${t("audit.inventory.guests")}` : "",
jobCount ? `${jobCount} ${t("audit.document.backup")}` : ""].filter(Boolean).join(" · ") || "—"
const capacity = o.capacity_known && o.used_percent !== undefined
? `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(Number(o.used_percent))} %`
: "—"
return [clean(o.storage), clean(o.type), clean(o.status) || t("audit.classifications.unverified"),
capacity, observed]
})]
}
}
if (f.check_id === "storage.thin_pool_overprovisioning" && records.length) {
const objects = records.map(row => {
const related = f.affected.filter(o => o.pool === row.pool)
const classification = related.some(o => o.classification === "warning") ? "warning"
: related.some(o => o.classification === "observation") ? "observation" : "conformant"
return { ...row, classification,
fact: related.map(reason).filter((v, i, all) => all.indexOf(v) === i).join(" · ") }
})
const pct = (value: unknown) => Number.isFinite(Number(value))
? `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(Number(value))} %` : "—"
return [group(label("records"), [label("resource"), label("capacity"), label("data"), label("metadata"), label("fact")],
objects, o => {
const allocation = Number.isFinite(Number(o.allocation_percent))
? `${pct(o.allocation_percent)} (${formatValue("allocated_bytes", Number(o.allocated_bytes), locale)} / ${formatValue("pool_bytes", Number(o.pool_bytes), locale)})` : "—"
return [clean(o.pool), allocation, pct(o.data_percent), pct(o.metadata_percent),
clean(o.fact) || state(o)]
})]
}
if (f.check_id === "backup.guest_coverage") {
const excluded = f.affected.filter(o => o.reason_key === "dataExcludedFromBackup")
const notSelected = f.affected.filter(o => o.reason_key !== "dataExcludedFromBackup")
const groups = []
if (notSelected.length) groups.push(group(label("unscheduled"), [label("guest"), label("result")],
notSelected, o => [guest(o), state(o)]))
if (excluded.length) {
const ids = [...new Set(excluded.map(o => o.vmid))]
groups.push(group(`${label("excludedDisks")} · ${excluded.length} / ${ids.length} ${label("guests")}`,
[label("guest"), label("disks"), label("result")], ids.map(vmid => ({...excluded.find(o => o.vmid === vmid)!, vmid})),
o => [guest(o), excluded.filter(d => d.vmid === o.vmid).map(d => clean(d.volume)).join(", "), state(o)]))
}
return groups
}
if (f.check_id === "backup.last_backup_age") {
return ["critical", "warning", "observation", "unverified"].flatMap(classification => {
const objects = f.affected.filter(o => status(o) === classification)
if (!objects.length) return []
const result = group(t(`audit.classifications.${classification}`),
[label("guest"), label("destination"), label("lastCopy"), label("backupAge"), label("backupLimit"), label("fact")], objects, o => {
const row = records.find(r => String(r.vmid) === String(o.vmid) && (
o.storage === "any" || r.expected_storage === o.storage ||
r.expected_storage === "any visible destination (no explicit target)" ||
r.storage === o.storage))
const basis = row?.age_policy === "declared recovery objective" ? label("limitDeclared")
: row?.age_policy === "schedule and grace" ? label("limitSchedule")
: typeof row?.age_policy === "string" && row.age_policy.startsWith("fallback;") ? label("limitReference") : ""
const limit = row?.max_age_hours != null && Number.isFinite(Number(row.max_age_hours))
? [auditDuration(Number(row.max_age_hours), locale), basis].filter(Boolean).join(" · ") : "—"
return [guest(o), o.storage === "any" ? label("noDestination") : clean(o.storage),
row?.last_backup ? new Date(Number(row.last_backup) * 1000).toLocaleString(locale) : classification === "unverified" ? t("audit.classifications.unverified") : label("notFound"),
row?.age_hours != null && Number.isFinite(Number(row.age_hours)) ? auditDuration(Number(row.age_hours), locale) : "—",
limit,
reason(o)]
})
// Shared limits retain their origin without repeating it for every guest.
const shared = [1,4,5].filter(index => result.rows.length > 1 && result.rows.every(row => row.cells[index] === result.rows[0].cells[index]))
result.note = shared.map(index => `${result.columns[index]}: ${result.rows[0].cells[index]}`).join(" · ")
result.columns = result.columns.filter((_,index) => !shared.includes(index))
result.rows.forEach(row => { row.cells = row.cells.filter((_,index) => !shared.includes(index)) })
return [result]
})
}
if (f.check_id === "backup.job_results") {
// The PVE task list may contain dozens of repetitions of the same
// failed job. One row per task obscures the useful facts, so retain
// the count, time range, final status and latest UPID per guest.
const merged = new Map<string, Record<string, unknown>>()
for (const item of f.affected) {
// If PVE supplied neither an id field nor a guest-bearing UPID,
// keep the task separate rather than combining unrelated failures.
const identity = clean(item.vmid) || clean(item.upid || item.job)
const key = `${identity}\u0000${clean(item.status)}`
const known = merged.get(key)
const currentMs = auditDate(item.when)?.getTime() ?? 0
if (!known) {
merged.set(key, { ...item, count: 1, first_seen: item.when,
last_seen: item.when, latest_job: item.upid || item.job,
_first_ms: currentMs, _last_ms: currentMs })
continue
}
known.count = Number(known.count || 0) + 1
if (currentMs && (!Number(known._first_ms) || currentMs < Number(known._first_ms))) {
known._first_ms = currentMs
known.first_seen = item.when
}
if (currentMs >= Number(known._last_ms || 0)) {
known._last_ms = currentMs
known.last_seen = item.when
known.latest_job = item.upid || item.job
}
}
const objects = [...merged.values()].sort((a, b) =>
Number(b._last_ms || 0) - Number(a._last_ms || 0))
return objects.length ? [group(t("audit.classifications.warning"),
[label("guest"), label("occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), label("detail"), label("technical")],
objects, o => [o.vmid === undefined || o.vmid === null ? "—" : guest(o),
clean(o.count) || "1", auditInstant(o.first_seen, locale),
auditInstant(o.last_seen, locale), clean(o.status) || reason(o),
clean(o.latest_job) || "—"])] : []
}
// The inventory already presents these events properly: what happened,
// how severe, how often, when it started, when it last happened and
// what the kernel actually said. Six rows reading "sdh · still
// reporting errors" described none of that, so the finding shows the
// same table the inventory does, grouped by device.
if (f.check_id === "hardware.disk_errors") {
const devices = Array.from(new Set(f.affected.map(o => clean(o.name))))
return devices.map(device => group(device,
[t("audit.document.event"), t("audit.document.severity"),
t("audit.document.occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), t("audit.document.detail")],
f.affected.filter(o => clean(o.name) === device),
o => [clean(o.type) || "—",
clean(o.severity) ? t(`audit.classifications.${
o.severity === "critical" ? "critical" : "warning"}`) : "—",
clean(o.count) || "—", auditInstant(o.first_seen, locale), auditInstant(o.last_seen, locale),
clean(o.message) || "—"]))
}
// Lynis repeats a warning once per thing it applies to: ten
// promiscuous interfaces are ten identical records. Printed one per
// row under a heading that already said the same sentence, thirteen
// warnings filled seventeen rows and a column whose only content was
// the identifier repeated from the heading beside it. Collapsed to one
// row per distinct warning, with how many times it was raised and what
// it named where Lynis said so.
if (f.check_id === "security.lynis_warnings") {
const seen = new Map<string, Record<string, unknown>[]>()
for (const o of f.affected) {
const key = `${clean(o.test)}\u0000${clean(o.message)}`
seen.set(key, [...(seen.get(key) || []), o])
}
const entries = [...seen.values()]
const detailed = entries.some(items => items.some(o => clean(o.details)))
// `occurrences` is worded for the middle of a sentence; the column
// header the disk table already uses reads correctly on its own.
const columns = [label("lynisTest"), label("lynisWarning"),
t("audit.document.occurrences")]
return [group(label("records"), detailed ? [...columns, label("detail")] : columns,
entries.map(items => items[0]), (o) => {
const items = seen.get(`${clean(o.test)}\u0000${clean(o.message)}`) || [o]
const cells = [clean(o.test) || "—", clean(o.message) || label("noDescription"),
String(items.length)]
if (!detailed) return cells
const named = [...new Set(items.map(i => clean(i.details)).filter(Boolean))]
return [...cells, named.join(", ") || "—"]
})]
}
return f.affected.length ? [group(label("records"), [label("resource"), label("fact"), label("result")], f.affected, o => {
const details = [clean(o.volume), clean(o.version), o.hours !== undefined ? auditDuration(Number(o.hours), locale) : ""].filter(Boolean).join(" · ")
return [resource(o), [reason(o), details].filter(Boolean).join(" · "), state(o)]
})] : []
}
export function affectedDescription(f: PresentedFinding, t: AuditTranslate): string {
const label = (key: string) => auditLabel(t, key)
if (f.check_id === "backup.guest_coverage") {
const disks = f.affected.filter(o => o.reason_key === "dataExcludedFromBackup").length
const guests = f.affected.length - disks
return [guests ? `${guests} ${label("unscheduled")}` : "", disks ? `${disks} ${label("excludedDisks")}` : ""].filter(Boolean).join(" · ")
}
return f.affected.length ? `${f.affected.length} ${label(f.check_id === "security.lynis_warnings" ? "occurrences" : "records")}` : ""
}
export function resultBreakdown(f: PresentedFinding, t: AuditTranslate): string {
const counts = new Map<string, number>()
for (const item of f.affected) {
const key = clean(item.classification) || f.classification
counts.set(key, (counts.get(key) || 0) + 1)
}
return [...counts].map(([key, count]) => `${count} · ${t(`audit.classifications.${key}`)}`).join(" / ")
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Turns a finding's evidence into something a reader can read.
*
* Checks record evidence in whatever shape suits what they examined:
* some serialise a list of objects, some an object of lists, some write
* a few lines of text. Printing that verbatim shows the reader a JSON
* dump and asks them to parse it — which defeats the purpose of evidence,
* which is to let someone verify a conclusion without trusting it.
*
* The parser recognises those shapes and returns blocks: a table for a
* list of records, labelled pairs for a single record, plain lines for
* the rest. Field names are humanised and values are formatted according
* to what the name says they are — a `_bytes` suffix is a size, `_hours`
* a duration, an `_at` an instant — so the reader sees "1.2 TiB" where
* the check wrote 1319413953331.
*
* Nothing is discarded: text the parser does not recognise is passed
* through as lines, because evidence that has been silently dropped is
* worse than evidence that is ugly.
*/
export type EvidenceBlock =
| { kind: "table"; title?: string; columns: string[]; rows: string[][] }
| { kind: "pairs"; title?: string; entries: Array<[string, string]> }
| { kind: "text"; title?: string; lines: string[] }
/** `max_age_hours` reads as "Max age hours"; `vmid` stays "VMID". */
const ACRONYMS: Record<string, string> = {
vmid: "VMID", id: "ID", cpu: "CPU", pci: "PCI", iommu: "IOMMU",
smart: "SMART", zfs: "ZFS", arc: "ARC", ssh: "SSH", lxc: "LXC",
pve: "PVE", pbs: "PBS", nfs: "NFS", url: "URL", os: "OS", ram: "RAM",
}
export function humanise(field: string): string {
const parts = field.replace(/[_-]+/g, " ").trim().split(/\s+/)
if (parts.length === 0) return field
return parts
.map((word, i) => {
const known = ACRONYMS[word.toLowerCase()]
if (known) return known
return i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
})
.join(" ")
}
function sizeOf(value: number): string {
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
let n = Math.abs(value), i = 0
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
const shown = n >= 100 || i < 2 ? Math.round(n) : Number(n.toFixed(1))
return `${value < 0 ? "-" : ""}${shown} ${units[i]}`
}
export function durationOf(hours: number, locale: string): string {
if (!Number.isFinite(hours) || hours < 0) return "—"
const total = Math.round(hours * 60)
const days = Math.floor(total / 1440), h = Math.floor(total % 1440 / 60)
const unit = (v: number, name: string) => new Intl.NumberFormat(locale, {
style: "unit", unit: name, unitDisplay: "short", maximumFractionDigits: 0,
}).format(v)
return [days ? unit(days, "day") : "", h || days ? unit(h, "hour") : "", unit(total % 60, "minute")].filter(Boolean).join(" ")
}
/**
* Formats one value using what its field name says it is. The name is
* the only type information a check leaves behind, so it is what the
* formatter reads.
*/
export function formatValue(field: string, value: unknown, locale: string,
units?: string): string {
if (value === null || value === undefined || value === "") return "—"
// A recorded `false` is an answer, and it used to render as the same
// dash as "nothing was recorded": a table of seven archives that are
// definitely gone read as seven about which nothing was known.
if (typeof value === "boolean") return value ? "✓" : "✗"
const name = field.toLowerCase()
if (typeof value === "number") {
// The field name is read before the record's declared units: a
// record of sizes still carries a timestamp and a percentage, and
// those are not sizes.
if (name.endsWith("_hours") || name === "hours") return durationOf(value, locale)
if (name.endsWith("_days") || name === "days") return `${Number(value.toFixed(1))} d`
if (name.endsWith("_percent") || name.endsWith("_pct")) {
return `${Number(value.toFixed(1))} %`
}
// A check records instants as epoch seconds under names like
// `last_backup` or `collected_at`, so both the name and the
// magnitude have to agree before a number is shown as a date.
const temporal = /(^|_)(at|time|date|seen|since|backup|run|checked|updated)$/
if (temporal.test(name) && Number.isFinite(value)
&& value > 1_000_000_000 && value < 4_000_000_000) {
return new Date(value * 1000).toLocaleString(locale)
}
if (name.endsWith("_bytes") || name === "bytes" || name.endsWith("_size")
|| units === "bytes") {
return sizeOf(value)
}
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value)
}
if (Array.isArray(value)) {
if (value.length === 0) return "—"
const shown = value.slice(0, 3).map((v) => {
if (v === null || typeof v !== "object") return String(v)
// Inside a cell, name each record by whatever identifies it
// rather than spelling out every field.
const record = v as Record<string, unknown>
const key = ["vmid", "id", "name", "device", "storage", "volume", "job"]
.find((k) => record[k] !== undefined)
return key ? String(record[key])
: Object.entries(record).map(([k, x]) => `${humanise(k)} ${String(x)}`).join(" ")
})
return shown.join(", ") + (value.length > 3 ? ` +${value.length - 3}` : "")
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>)
.map(([k, v]) => `${humanise(k)}: ${formatValue(k, v, locale)}`)
.join(" · ")
}
return String(value)
}
function isRecordList(value: unknown): value is Array<Record<string, unknown>> {
return Array.isArray(value) && value.length > 0 &&
value.every((v) => v !== null && typeof v === "object" && !Array.isArray(v))
}
function tableFrom(records: Array<Record<string, unknown>>, locale: string,
title?: string): EvidenceBlock[] {
// Union of the keys, in first-seen order: records from one check are
// uniform in practice, but a missing key must not shift a column.
const columns: string[] = []
for (const record of records) {
for (const key of Object.keys(record)) {
if (!columns.includes(key)) columns.push(key)
}
}
const cells = records.map((r) => {
const units = typeof r.units === "string" ? r.units : undefined
return Object.fromEntries(
columns.map((c) => [c, formatValue(c, r[c], locale, units)]))
})
// A column holding the same value in every row is a property of the
// whole set, not of any row. Stating it once keeps the table narrow
// enough to read; it only pays off once the table is already wide.
const constant: Array<[string, string]> = []
const varying = columns.filter((c) => {
if (columns.length <= 6 || records.length < 2) return true
const first = cells[0][c]
if (!cells.every((row) => row[c] === first) || first === "—") return true
constant.push([humanise(c), first])
return false
})
const rows = cells.map((row) => varying.map((c) => row[c]))
return constant.length
? [{ kind: "pairs" as const, title, entries: constant },
{ kind: "table" as const, columns: varying.map(humanise), rows }]
: [{ kind: "table" as const, title, columns: varying.map(humanise), rows }]
}
function blocksFromValue(value: unknown, locale: string,
title?: string): EvidenceBlock[] {
if (isRecordList(value)) return tableFrom(value, locale, title)
if (Array.isArray(value)) {
return value.length
? [{ kind: "text", title, lines: value.map((v) => formatValue("", v, locale)) }]
: []
}
if (value !== null && typeof value === "object") {
const blocks: EvidenceBlock[] = []
const pairs: Array<[string, string]> = []
const record = value as Record<string, unknown>
const units = typeof record.units === "string" ? record.units : undefined
for (const [key, inner] of Object.entries(record)) {
// A nested list of records earns its own table under its own name;
// everything else stays a labelled pair.
if (isRecordList(inner)) {
blocks.push(...tableFrom(inner, locale, humanise(key)))
} else {
pairs.push([humanise(key), formatValue(key, inner, locale, units)])
}
}
if (pairs.length) blocks.unshift({ kind: "pairs", title, entries: pairs })
return blocks
}
return [{ kind: "text", title, lines: [formatValue("", value, locale)] }]
}
/**
* Text evidence: lines like `label:` introduce the indented lines under
* them, which is the shape checks write by hand.
*/
function blocksFromText(text: string, locale: string): EvidenceBlock[] {
const lines = text.split("\n")
const blocks: EvidenceBlock[] = []
let title: string | undefined
let buffer: string[] = []
const flush = () => {
const kept = buffer.filter((l) => l.trim())
const joined = kept.join("\n").trim()
// A section introduced by a heading gets the same treatment as
// evidence that is JSON from the first character.
let sectionTitle = title
let source = joined
if (joined && !/^[[{]/.test(joined)) {
const at = joined.search(/:\s*[[{]/)
// Only a short prefix is a label; a paragraph that happens to
// mention a bracket is prose.
if (at > 0 && at < 80) {
sectionTitle = title || joined.slice(0, at).trim()
source = joined.slice(joined.indexOf(joined[at] === ":" ? ":" : ":", at) + 1).trim()
}
}
const split = source ? splitLeadingJson(source) : null
if (split) {
const [value, rest] = split
blocks.push(...blocksFromValue(value, locale, sectionTitle))
if (rest) blocks.push({ kind: "text", lines: rest.split("\n") })
buffer = []
return
}
if (kept.length || title) blocks.push({ kind: "text", title, lines: kept })
buffer = []
}
for (const line of lines) {
const heading = /^(\S[^:]*):\s*$/.exec(line)
if (heading) {
flush()
title = heading[1]
continue
}
buffer.push(line.replace(/^\s{1,4}/, ""))
}
flush()
return blocks.filter((b) => b.kind !== "text" || b.lines.length || b.title)
}
/**
* Splits a leading JSON document from whatever text follows it, by
* balancing brackets outside of strings. Checks routinely serialise
* their records and then add a line qualifying them, and both halves
* are evidence.
*/
export function splitLeadingJson(text: string): [unknown, string] | null {
const open = text[0]
if (open !== "{" && open !== "[") return null
const close = open === "{" ? "}" : "]"
let depth = 0, inString = false, escaped = false, end = -1
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (escaped) { escaped = false; continue }
if (c === "\\") { escaped = true; continue }
if (c === '"') { inString = !inString; continue }
if (inString) continue
if (c === open) depth++
else if (c === close && --depth === 0) { end = i + 1; break }
}
if (end < 0) return null
try {
return [JSON.parse(text.slice(0, end)), text.slice(end).trim()]
} catch {
return null
}
}
/** Parses one finding's evidence into blocks a reader can read. */
export function parseEvidence(evidence: string | null,
locale = "en"): EvidenceBlock[] {
if (!evidence) return []
const text = evidence.trim()
if (!text) return []
const split = splitLeadingJson(text)
if (split) {
const [value, rest] = split
const blocks = blocksFromValue(value, locale)
return rest ? blocks.concat(blocksFromText(rest, locale)) : blocks
}
return blocksFromText(text, locale)
}
+556
View File
@@ -0,0 +1,556 @@
/**
* Inline SVG diagrams for the audit report.
*
* The inventory already resolves how the pieces of a node connect; a
* diagram is what makes those relations legible at a glance. Drawn as
* SVG with no dependency so the document stays self-contained and prints
* as vector rather than as a screenshot.
*
* Colours come from the report stylesheet's palette so a diagram reads
* as part of the document and not as an embedded picture.
*/
import { esc } from "./report-shell"
const INK = "#0f172a"
const MUTED = "#64748b"
const LINE = "#94a3b8"
const FILL = "#f8fafc"
const EDGE = "#e2e8f0"
const ACCENT = "#06b6d4"
const WARN = "#ca8a04"
/**
* Approximate width of a string at a given size.
*
* SVG has no layout: text drawn wider than its box simply spills over
* it. Measuring properly needs the font metrics, which are not available
* while composing the document, so widths are estimated per character
* class — narrow, wide and everything else — which is close enough to
* decide where to cut.
*/
function textWidth(text: string, size: number, bold = false): number {
let units = 0
for (const c of text) {
if ("iljI.,:;'|! ".includes(c)) units += 0.30
else if ("mwMW@".includes(c)) units += 0.92
else if (c >= "A" && c <= "Z") units += 0.68
else if (c >= "0" && c <= "9") units += 0.56
else units += 0.54
}
return units * size * (bold ? 1.06 : 1)
}
/** Cuts a label to what fits, marking the cut. */
function fit(text: string, width: number, size: number, bold = false): string {
if (textWidth(text, size, bold) <= width) return text
let out = text
while (out.length > 1 && textWidth(out + "…", size, bold) > width) {
out = out.slice(0, -1)
}
return out.trimEnd() + "…"
}
/**
* Processor models carry trademark noise and a clock the diagram states
* elsewhere. The part a reader identifies the chip by is the family and
* the model number.
*/
export function shortenCpu(model: string): string {
return (model || "")
.replace(/\((?:R|TM|r|tm)\)/g, "")
.replace(/\b(CPU|Processor)\b/gi, "")
.replace(/\s*@.*$/, "")
.replace(/\s{2,}/g, " ")
.trim()
}
interface Node { id: string; label: string; sub?: string; tone?: "plain" | "accent" | "warn" }
function box(x: number, y: number, w: number, h: number, n: Node): string {
const stroke = n.tone === "accent" ? ACCENT : n.tone === "warn" ? WARN : EDGE
const inner = w - 12
return `<g>
<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="5"
fill="${FILL}" stroke="${stroke}" stroke-width="1.5"/>
<text x="${x + w / 2}" y="${y + (n.sub ? h / 2 - 3 : h / 2 + 4)}" text-anchor="middle"
font-size="11" font-weight="600" fill="${INK}">${esc(fit(n.label, inner, 11, true))}</text>
${n.sub ? `<text x="${x + w / 2}" y="${y + h / 2 + 11}" text-anchor="middle"
font-size="9" fill="${MUTED}">${esc(fit(n.sub, inner, 9))}</text>` : ""}
</g>`
}
function arrow(x1: number, y1: number, x2: number, y2: number): string {
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${LINE}"
stroke-width="1.4" marker-end="url(#pmx-arrow)"/>`
}
const DEFS = `<defs>
<marker id="pmx-arrow" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="${LINE}"/>
</marker>
</defs>`
function svg(width: number, height: number, body: string): string {
// A viewBox with no fixed width lets the diagram scale to the column on
// screen and to the page when printed, without a second layout.
return `<svg viewBox="0 0 ${width} ${height}" width="100%" role="img"
preserveAspectRatio="xMidYMin meet"
style="display:block;height:auto">${DEFS}${body}</svg>`
}
/**
* Network path: physical interfaces, the bond that groups them when one
* exists, each bridge and the guests attached to it. This is the chain a
* reader would otherwise reconstruct by hand from three separate lists.
*/
export function networkDiagram(
bridges: Record<string, any>,
guests: Array<{ vmid: number; name: string; interfaces: Array<{ bridge: string }> }>,
labels: { nic: string; bond: string; bridge: string; guests: string },
): string {
const entries = Object.entries(bridges || {})
if (entries.length === 0) return ""
const COL_W = 132, BOX_H = 34, GAP_Y = 12, PAD = 12
const rows: Array<{ nics: Node[]; bond: Node | null; bridge: Node; count: number }> = []
for (const [id, b] of entries) {
const hops = (b.uplink || []) as Array<{ kind: string; id: string; mode?: string }>
const bond = hops.find((h) => h.kind === "bond")
const nics = hops.filter((h) => h.kind === "nic")
const attached = guests.filter((g) =>
(g.interfaces || []).some((n) => n.bridge === id)).length
rows.push({
nics: nics.length ? nics.map((n) => ({ id: n.id, label: n.id }))
: [{ id: `${id}-none`, label: "—", tone: "warn" as const }],
bond: bond ? { id: bond.id, label: bond.id, sub: bond.mode, tone: "accent" as const } : null,
bridge: { id, label: id, tone: "accent" as const },
count: attached,
})
}
// A host with no bond has no bond column: keeping the caption over an
// empty lane invites the reader to look for something that is not
// there, and leaves the diagram a quarter wider than it needs to be.
const hasBond = rows.some((r) => r.bond)
const bridgeCol = hasBond ? 2 : 1
const guestsCol = bridgeCol + 1
const height = PAD * 2 + rows.reduce((h, r) =>
h + Math.max(r.nics.length, 1) * (BOX_H + GAP_Y), 0)
const width = COL_W * (guestsCol + 1) + PAD * 2
let y = PAD
const parts: string[] = []
// Column captions
const captions = hasBond
? [labels.nic, labels.bond, labels.bridge, labels.guests]
: [labels.nic, labels.bridge, labels.guests]
parts.push(captions.map((c, i) =>
`<text x="${PAD + COL_W * i + COL_W / 2}" y="${PAD - 2}" text-anchor="middle"
font-size="9" font-weight="700" letter-spacing="0.06em"
fill="${MUTED}">${esc(c.toUpperCase())}</text>`).join(""))
y += 8
for (const row of rows) {
const block = Math.max(row.nics.length, 1) * (BOX_H + GAP_Y)
const midY = y + block / 2 - BOX_H / 2
row.nics.forEach((n, i) => {
const ny = y + i * (BOX_H + GAP_Y)
parts.push(box(PAD, ny, COL_W - 20, BOX_H, n))
const target = row.bond ? PAD + COL_W : PAD + COL_W * bridgeCol
parts.push(arrow(PAD + COL_W - 20, ny + BOX_H / 2, target, midY + BOX_H / 2))
})
if (row.bond) {
parts.push(box(PAD + COL_W, midY, COL_W - 20, BOX_H, row.bond))
parts.push(arrow(PAD + COL_W * bridgeCol - 20, midY + BOX_H / 2,
PAD + COL_W * bridgeCol, midY + BOX_H / 2))
}
parts.push(box(PAD + COL_W * bridgeCol, midY, COL_W - 20, BOX_H, row.bridge))
parts.push(arrow(PAD + COL_W * guestsCol - 20, midY + BOX_H / 2,
PAD + COL_W * guestsCol, midY + BOX_H / 2))
parts.push(box(PAD + COL_W * guestsCol, midY, COL_W - 20, BOX_H,
{ id: `${row.bridge.id}-g`, label: String(row.count), sub: labels.guests }))
y += block
}
return svg(width, height + 8, parts.join(""))
}
/**
* Where each guest's disks live, and what protects them. Storages and
* backup destinations are drawn once with the guests that depend on
* them, which is what turns two lists into a dependency picture.
*/
export function storageDiagram(
guests: Array<{
vmid: number; name: string
disks: Array<{ storage: string | null }>
backups: Array<{ storage: string }>
}>,
labels: { guests: string; storage: string; backup: string; unprotected: string },
): string {
const storages = new Map<string, number>()
const destinations = new Map<string, number>()
let unprotected = 0
for (const g of guests || []) {
for (const storage of new Set((g.disks || []).map(d => d.storage).filter(Boolean))) {
if (storage) storages.set(storage, (storages.get(storage) || 0) + 1)
}
if ((g.backups || []).length === 0) unprotected += 1
for (const storage of new Set((g.backups || []).map(b => b.storage))) {
destinations.set(storage, (destinations.get(storage) || 0) + 1)
}
}
if (storages.size === 0) return ""
const COL_W = 168, BOX_H = 34, GAP_Y = 12, PAD = 12
const left = [...storages.entries()].sort()
const right = [...destinations.entries()].sort()
const lanes = Math.max(left.length, right.length + (unprotected ? 1 : 0), 1)
const height = PAD * 2 + 10 + lanes * (BOX_H + GAP_Y)
const width = COL_W * 3 + PAD * 2
const parts: string[] = []
parts.push([labels.storage, labels.guests, labels.backup].map((c, i) =>
`<text x="${PAD + COL_W * i + COL_W / 2}" y="${PAD - 2}" text-anchor="middle"
font-size="9" font-weight="700" letter-spacing="0.06em"
fill="${MUTED}">${esc(c.toUpperCase())}</text>`).join(""))
const centreY = PAD + 8 + (lanes * (BOX_H + GAP_Y)) / 2 - BOX_H / 2
parts.push(box(PAD + COL_W, centreY, COL_W - 24, BOX_H, {
id: "guests", label: String((guests || []).length), sub: labels.guests, tone: "accent",
}))
left.forEach(([name, count], i) => {
const y = PAD + 8 + i * (BOX_H + GAP_Y)
parts.push(box(PAD, y, COL_W - 24, BOX_H, { id: name, label: name, sub: `${count}` }))
parts.push(arrow(PAD + COL_W - 24, y + BOX_H / 2, PAD + COL_W, centreY + BOX_H / 2))
})
right.forEach(([name, count], i) => {
const y = PAD + 8 + i * (BOX_H + GAP_Y)
parts.push(box(PAD + COL_W * 2, y, COL_W - 24, BOX_H,
{ id: name, label: name, sub: `${count}` }))
parts.push(arrow(PAD + COL_W * 2 - 24, centreY + BOX_H / 2, PAD + COL_W * 2, y + BOX_H / 2))
})
if (unprotected > 0) {
const y = PAD + 8 + right.length * (BOX_H + GAP_Y)
parts.push(box(PAD + COL_W * 2, y, COL_W - 24, BOX_H, {
id: "unprotected", label: String(unprotected), sub: labels.unprotected,
}))
parts.push(arrow(PAD + COL_W * 2 - 24, centreY + BOX_H / 2, PAD + COL_W * 2, y + BOX_H / 2))
}
return svg(width, height, parts.join(""))
}
/**
* Findings per area and state, as a stacked bar. A table of counts is
* exact but does not show where the weight of the assessment sits.
*/
export function findingsChart(
byArea: Record<string, Record<string, number>>,
areaLabel: (a: string) => string,
stateColor: Record<string, string>,
order: string[],
): string {
const areas = Object.keys(byArea).sort()
if (areas.length === 0) return ""
const ROW_H = 26, PAD = 12, LABEL_W = 128, BAR_W = 320
const max = Math.max(...areas.map((a) =>
order.reduce((s, st) => s + (byArea[a][st] || 0), 0)), 1)
const height = PAD * 2 + areas.length * ROW_H
const width = LABEL_W + BAR_W + PAD * 2 + 30
const parts = areas.map((a, i) => {
const y = PAD + i * ROW_H
let x = LABEL_W
const total = order.reduce((s, st) => s + (byArea[a][st] || 0), 0)
const segs = order.filter((st) => byArea[a][st]).map((st) => {
const w = (byArea[a][st] / max) * BAR_W
const seg = `<rect x="${x}" y="${y + 5}" width="${w}" height="14" rx="2"
fill="${stateColor[st] || LINE}"><title>${esc(st)}: ${byArea[a][st]}</title></rect>`
x += w
return seg
}).join("")
return `<text x="${LABEL_W - 8}" y="${y + 16}" text-anchor="end" font-size="10"
fill="${INK}">${esc(areaLabel(a))}</text>${segs}
<text x="${x + 6}" y="${y + 16}" font-size="10" fill="${MUTED}">${total}</text>`
})
return svg(width, height, parts.join(""))
}
/**
* How the node is built: the chassis and what is seated in it.
*
* Read left to right as the machine is assembled — processor and memory
* on the board, the controllers the board exposes, and what hangs off
* each controller. Drawn as nested frames rather than as a graph,
* because containment is what the reader is being told: this disk is
* behind that controller, these modules sit in those slots.
*/
export function nodeArchitectureDiagram(
hw: any,
identity: { node?: string; pve_version?: string },
labels: {
chassis: string; processor: string; memory: string
controllers: string; disks: string; adapters: string
slotsUsed: string; cores: string; threads: string; empty: string
},
): string {
if (!hw) return ""
const PAD = 14, W = 860
const parts: string[] = []
let y = PAD + 18
const frame = (title: string, x: number, w: number, top: number, h: number) => {
parts.push(`<rect x="${x}" y="${top}" width="${w}" height="${h}" rx="7"
fill="none" stroke="${EDGE}" stroke-width="1.5"/>
<rect x="${x + 12}" y="${top - 6}" width="${title.length * 6.2 + 12}" height="12"
fill="#ffffff"/>
<text x="${x + 18}" y="${top + 3}" font-size="9" font-weight="700"
letter-spacing="0.08em" fill="${MUTED}">${esc(title.toUpperCase())}</text>`)
}
const chip = (x: number, top: number, w: number, h: number,
title: string, lines: string[], tone: "plain" | "accent" | "warn" = "plain") => {
const stroke = tone === "accent" ? ACCENT : tone === "warn" ? WARN : EDGE
const inner = w - 12
parts.push(`<rect x="${x}" y="${top}" width="${w}" height="${h}" rx="5"
fill="${FILL}" stroke="${stroke}" stroke-width="1.5"/>
<text x="${x + w / 2}" y="${top + 15}" text-anchor="middle" font-size="10.5"
font-weight="600" fill="${INK}">${esc(fit(title, inner, 10.5, true))}</text>` +
lines.map((l, i) => `<text x="${x + w / 2}" y="${top + 29 + i * 11}"
text-anchor="middle" font-size="9" fill="${MUTED}">${esc(fit(l, inner, 9))}</text>`).join(""))
}
// Board: processor and memory slots.
const cpu = hw.cpu || {}
const mem = hw.memory || {}
const modules: any[] = mem.modules || []
const slots = mem.slots || modules.length
const boardH = 76
frame(labels.chassis, PAD, W - PAD * 2, y, boardH)
const model = shortenCpu(cpu.model) || labels.processor
const cpuW = Math.min(250, Math.max(150, textWidth(model, 10.5, true) + 20))
chip(PAD + 14, y + 14, cpuW, 48, model, [
`${cpu.sockets || 1} × ${cpu.cores_per_socket || "?"} ${labels.cores}`,
`${cpu.threads || "?"} ${labels.threads}`,
], "accent")
// One tile per slot, so an empty slot is as visible as a filled one.
// The tiles share what the processor leaves, gaps included, so a board
// with many slots narrows them rather than dropping the last one.
const count = Math.max(slots, modules.length, 1)
const slotArea = W - PAD * 2 - cpuW - 42
const tileW = Math.min(96, Math.max(34, (slotArea - (count - 1) * 6) / count))
for (let i = 0; i < count; i++) {
const m = modules[i]
const x = PAD + 28 + cpuW + i * (tileW + 6)
if (x + tileW > W - PAD - 8) break
chip(x, y + 14, tileW, 48, m ? String(m.size || "") : labels.empty,
m ? [String(m.type || ""), String(m.speed || "")] : [],
m ? "plain" : "warn")
}
parts.push(`<text x="${W - PAD - 6}" y="${y + boardH + 12}" text-anchor="end"
font-size="9" fill="${MUTED}">${esc(labels.memory)}: ${mem.populated || 0}/${slots || "?"} ${esc(labels.slotsUsed)}</text>`)
y += boardH + 26
// Controllers, with what each one carries underneath.
const controllers: any[] = hw.controllers || []
const disks: any[] = hw.disks || []
const adapters: any[] = hw.adapters || []
const byBus = new Map<string, any[]>()
for (const d of disks) {
const bus = d.bus || labels.disks
byBus.set(bus, [...(byBus.get(bus) || []), d])
}
const groups: Array<{ title: string; sub: string; items: string[] }> = []
for (const [bus, list] of [...byBus.entries()].sort()) {
const kind = bus === "nvme" ? "Non-Volatile memory controller"
: bus === "sata" ? "SATA controller" : ""
const count = controllers.filter((c) => c.class === kind).length
groups.push({
title: bus.toUpperCase(),
sub: count ? `${count} ${labels.controllers.toLowerCase()}` : labels.controllers.toLowerCase(),
items: list.map((d) => `${d.name} · ${d.rotational ? "HDD" : "SSD"}`),
})
}
if (adapters.length) {
groups.push({
title: labels.adapters.toUpperCase(),
sub: `${adapters.length}`,
items: adapters.map((a) =>
`${a.name}${a.speed_mbps ? ` · ${a.speed_mbps >= 1000
? `${a.speed_mbps / 1000}G` : `${a.speed_mbps}M`}` : ""}`),
})
}
if (groups.length === 0) return svg(W, y + PAD, parts.join(""))
const colW = (W - PAD * 2 - (groups.length - 1) * 10) / groups.length
const rows = Math.max(...groups.map((g) => g.items.length))
const groupH = 34 + Math.min(rows, 8) * 15 + 10
groups.forEach((g, i) => {
const x = PAD + i * (colW + 10)
parts.push(`<rect x="${x}" y="${y}" width="${colW}" height="${groupH}" rx="6"
fill="none" stroke="${EDGE}" stroke-width="1.5"/>
<rect x="${x}" y="${y}" width="${colW}" height="24" rx="6" fill="${FILL}"/>
<text x="${x + colW / 2}" y="${y + 16}" text-anchor="middle" font-size="10"
font-weight="700" fill="${INK}">${esc(fit(g.title, colW - 12, 10, true))}</text>` +
g.items.slice(0, 8).map((item, j) =>
`<text x="${x + 10}" y="${y + 39 + j * 15}" font-size="9.5"
fill="${MUTED}">${esc(fit(item, colW - 20, 9.5))}</text>`).join("") +
(g.items.length > 8
? `<text x="${x + 10}" y="${y + 39 + 8 * 15}" font-size="9" fill="${MUTED}">+${g.items.length - 8}</text>`
: ""))
// Tie each group back to the board it hangs from.
parts.push(`<line x1="${x + colW / 2}" y1="${y - 12}" x2="${x + colW / 2}" y2="${y}"
stroke="${LINE}" stroke-width="1.2"/>`)
})
return svg(W, y + groupH + PAD, parts.join(""))
}
/**
* Cluster membership: every configured node, which one this report
* describes, and whether the node currently sees it.
*/
export function clusterDiagram(
cluster: any,
labels: { thisNode: string; unreachable: string; links: string },
): string {
if (!cluster || !(cluster.nodes || []).length) return ""
const nodes: any[] = cluster.nodes
const PAD = 16, BOX_W = 132, BOX_H = 46, GAP = 16
const perRow = Math.min(nodes.length, 5)
const rowCount = Math.ceil(nodes.length / perRow)
const width = PAD * 2 + perRow * BOX_W + (perRow - 1) * GAP
const busY = PAD + 22
const height = busY + 26 + rowCount * (BOX_H + 26) + PAD
const parts: string[] = []
// The corosync ring, drawn as the bus every node attaches to.
parts.push(`<line x1="${PAD}" y1="${busY}" x2="${width - PAD}" y2="${busY}"
stroke="${ACCENT}" stroke-width="2"/>
<text x="${PAD}" y="${busY - 7}" font-size="9" font-weight="700"
letter-spacing="0.08em" fill="${MUTED}">${esc(
`${cluster.name} · ${cluster.links || 1} ${labels.links}`.toUpperCase())}</text>`)
nodes.forEach((n, i) => {
const row = Math.floor(i / perRow), col = i % perRow
const x = PAD + col * (BOX_W + GAP)
const y = busY + 26 + row * (BOX_H + 26)
parts.push(`<line x1="${x + BOX_W / 2}" y1="${busY}" x2="${x + BOX_W / 2}" y2="${y}"
stroke="${LINE}" stroke-width="1.2"/>`)
const offline = n.online === false
const stroke = offline ? WARN : n.local ? ACCENT : EDGE
parts.push(`<rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6"
fill="${FILL}" stroke="${stroke}" stroke-width="${n.local ? 2 : 1.5}"/>
<text x="${x + BOX_W / 2}" y="${y + 19}" text-anchor="middle" font-size="11"
font-weight="600" fill="${INK}">${esc(n.name)}</text>
<text x="${x + BOX_W / 2}" y="${y + 32}" text-anchor="middle" font-size="9"
fill="${MUTED}">${esc(n.ring0_addr || "")}</text>
<text x="${x + BOX_W / 2}" y="${y + 42}" text-anchor="middle" font-size="8.5"
fill="${offline ? WARN : MUTED}">${esc(
offline ? labels.unreachable : n.local ? labels.thisNode : `id ${n.nodeid}`)}</text>`)
})
return svg(width, height, parts.join(""))
}
/**
* Latency over the reported window, one line per target.
*
* Averages say what is normal; the shape says whether it stayed that
* way. A table of min/avg/max cannot show a link that was fine except
* for twenty minutes, which is the reading the chart exists for.
*/
export function latencyChart(
targets: Array<{
target: string; label?: string
series: Array<{ t: number; v: number; max?: number | null }>
}>,
labels: { ms: string; hours: string },
): string {
const drawn = targets.filter((t) => (t.series || []).length > 1)
if (drawn.length === 0) return ""
const PAD = 12, LEFT = 46, BOTTOM = 24, W = 760, H = 210
const plotW = W - LEFT - PAD, plotH = H - PAD - BOTTOM
const all = drawn.flatMap((t) => t.series)
const times = all.map((s) => s.t)
const t0 = Math.min(...times), t1 = Math.max(...times)
// The ceiling covers the peaks, so the chart cannot disagree with the
// maximum the table reports.
const peak = Math.max(...all.map((s) => Math.max(s.v, s.max ?? 0)), 1)
const top = niceCeiling(peak)
const colors = [ACCENT, "#7c3aed", "#ca8a04"]
const x = (t: number) => LEFT + (t1 === t0 ? plotW : ((t - t0) / (t1 - t0)) * plotW)
const y = (v: number) => PAD + plotH - (Math.min(v, top) / top) * plotH
const parts: string[] = []
for (let i = 0; i <= 4; i++) {
const value = (top / 4) * i
const gy = y(value)
parts.push(`<line x1="${LEFT}" y1="${gy}" x2="${W - PAD}" y2="${gy}"
stroke="${EDGE}" stroke-width="1"/>
<text x="${LEFT - 6}" y="${gy + 3}" text-anchor="end" font-size="9"
fill="${MUTED}">${axisLabel(value)}</text>`)
}
parts.push(`<text x="${PAD - 4}" y="${PAD + 4}" font-size="9" fill="${MUTED}">${esc(labels.ms)}</text>`)
drawn.forEach((t, i) => {
const color = colors[i % colors.length]
const points = t.series
// The band spans each sample's peak, the line its average: one shows
// what the link usually does, the other what it did at worst.
if (points.some((s) => typeof s.max === "number")) {
const area = points.map((s, j) =>
`${j === 0 ? "M" : "L"}${x(s.t).toFixed(1)} ${y(s.max ?? s.v).toFixed(1)}`).join(" ")
const back = points.slice().reverse().map((s) =>
`L${x(s.t).toFixed(1)} ${y(s.v).toFixed(1)}`).join(" ")
parts.push(`<path d="${area} ${back} Z" fill="${color}" fill-opacity="0.13"
stroke="none"/>`)
}
const line = points
.map((s, j) => `${j === 0 ? "M" : "L"}${x(s.t).toFixed(1)} ${y(s.v).toFixed(1)}`)
.join(" ")
parts.push(`<path d="${line}" fill="none" stroke="${color}"
stroke-width="1.4" stroke-linejoin="round"/>`)
const legendX = LEFT + i * 150
parts.push(`<rect x="${legendX}" y="${H - 13}" width="9" height="3" rx="1.5"
fill="${color}"/>
<text x="${legendX + 14}" y="${H - 9}" font-size="9"
fill="${MUTED}">${esc(fit(t.label || t.target, 130, 9))}</text>`)
})
const span = Math.max(1, Math.round((t1 - t0) / 3600))
parts.push(`<text x="${W - PAD}" y="${H - 9}" text-anchor="end" font-size="9"
fill="${MUTED}">${esc(`${span} ${labels.hours}`)}</text>`)
return svg(W, H, parts.join(""))
}
/** A ceiling that divides into four readable gridlines. */
function niceCeiling(peak: number): number {
const magnitude = Math.pow(10, Math.floor(Math.log10(peak)))
for (const step of [1, 2, 2.5, 5, 10]) {
const candidate = step * magnitude
if (candidate >= peak) return candidate
}
return 10 * magnitude
}
function axisLabel(value: number): string {
if (value === 0) return "0"
// Gridlines land on quarters of the ceiling, so halves are common;
// rounding them away would put a label where the line is not.
return String(Number(value.toFixed(Number.isInteger(value) ? 0 : 1)))
}
+496
View File
@@ -0,0 +1,496 @@
/**
* Shared shell for ProxMenux Monitor reports.
*
* The SMART, latency and audit reports are one family: same header with
* the product mark and a report identifier, numbered sections, the same
* cards, tables and callouts, the same dark action bar on screen that
* disappears when printing. This module holds that common language so a
* new report joins the family instead of inventing its own.
*
* The stylesheet is the one the SMART report established, kept verbatim
* so the two documents are indistinguishable side by side.
*/
export const REPORT_CSS = ` * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
@page { margin: 10mm; size: A4; }
/* === SCREEN: responsive layout === */
@media screen {
body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; overflow-x: hidden; }
}
@media screen and (max-width: 640px) {
body { padding: 16px; padding-top: 64px; }
.grid-4 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr; }
.rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.rpt-header-right { text-align: left; }
.exec-box { flex-wrap: wrap; }
.card-c .card-value { font-size: 16px; }
}
/* === PRINT: force desktop A4 layout from any device === */
@media print {
html, body { margin: 0 !important; padding: 0 !important; width: 100% !important; max-width: none !important; }
.no-print { display: none !important; }
.top-bar { display: none !important; }
.page-break { page-break-before: always; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { font-size: 11px; padding-top: 0 !important; }
/* Force desktop grid layout regardless of viewport */
.grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr !important; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr !important; }
.grid-2 { grid-template-columns: 1fr 1fr !important; }
.rpt-header { flex-direction: row !important; align-items: center !important; }
.rpt-header-right { text-align: right !important; }
.exec-box { flex-wrap: nowrap !important; }
.card-c .card-value { font-size: 20px !important; }
/* Page break control */
.section { page-break-inside: avoid; break-inside: avoid; margin-bottom: 15px; }
.exec-box { page-break-inside: avoid; break-inside: avoid; }
.card { page-break-inside: avoid; break-inside: avoid; }
.grid-2, .grid-3, .grid-4 { page-break-inside: avoid; break-inside: avoid; }
.section-title { page-break-after: avoid; break-after: avoid; }
.attr-tbl tr { page-break-inside: avoid; break-inside: avoid; }
.attr-tbl thead { display: table-header-group; }
.rpt-footer { page-break-inside: avoid; break-inside: avoid; margin-top: 20px; }
svg { max-width: 100%; height: auto; }
/* Darken light grays for PDF readability */
.rpt-header-left p, .rpt-header-right { color: #374151; }
.rpt-header-right .rid { color: #4b5563; }
.exec-text p { color: #374151; }
.card-label { color: #4b5563; }
.rpt-footer { color: #4b5563; }
[style*="color:#64748b"] { color: #374151 !important; }
[style*="color:#94a3b8"] { color: #4b5563 !important; }
[style*="color: #64748b"] { color: #374151 !important; }
[style*="color: #94a3b8"] { color: #4b5563 !important; }
[style*="color:#16a34a"], [style*="color: #16a34a"] { color: #16a34a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
[style*="color:#dc2626"] { color: #dc2626 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
[style*="color:#ca8a04"] { color: #ca8a04 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.health-ring, .card-value, .f-tag { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
/* Top bar for screen only */
.top-bar {
position: fixed; top: 0; left: 0; right: 0; background: #0f172a; color: #e2e8f0;
padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; z-index: 100;
font-size: 13px;
}
.top-bar-left { display: flex; align-items: center; gap: 12px; }
.top-bar-title { font-weight: 600; }
.top-bar-subtitle { font-size: 11px; color: #94a3b8; }
.top-bar button {
background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.top-bar button:hover { background: #0891b2; }
.top-bar .btn-group { display: flex; gap: 8px; }
.top-bar button svg { width: 18px; height: 18px; display: block; }
/* Header */
.rpt-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 0; border-bottom: 3px solid #0f172a; margin-bottom: 22px;
}
.rpt-header-left { display: flex; align-items: center; gap: 14px; }
.rpt-header-left img { height: 44px; width: auto; }
.rpt-header-left h1 { font-size: 22px; font-weight: 700; color: #0f172a; }
.rpt-header-left p { font-size: 11px; color: #64748b; }
.rpt-header-right { text-align: right; font-size: 11px; color: #64748b; line-height: 1.6; }
.rpt-header-right .rid { font-family: monospace; font-size: 10px; color: #94a3b8; }
/* Sections */
.section { margin-bottom: 22px; }
.section-title {
font-size: 14px; font-weight: 700; color: #0f172a; text-transform: uppercase;
letter-spacing: 0.05em; padding-bottom: 5px; border-bottom: 2px solid #e2e8f0; margin-bottom: 12px;
}
/* Executive summary */
.exec-box {
display: flex; align-items: flex-start; gap: 20px; padding: 20px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px;
}
.health-ring {
width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
align-items: center; justify-content: center; border: 4px solid; flex-shrink: 0;
}
.health-icon { font-size: 32px; line-height: 1; }
.health-lbl { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; margin-top: 4px; }
.exec-text { flex: 1; min-width: 200px; }
.exec-text h3 { font-size: 16px; margin-bottom: 4px; }
.exec-text p { font-size: 12px; color: #64748b; line-height: 1.5; }
/* Grids */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
.card-c { text-align: center; }
.card-c .card-value { font-size: 20px; font-weight: 800; }
/* Tags */
.f-tag { font-size: 9px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
/* Tables */
.attr-tbl { width: 100%; border-collapse: collapse; font-size: 11px; }
.attr-tbl th { text-align: left; padding: 6px 4px; font-size: 10px; color: #64748b; font-weight: 600; border-bottom: 2px solid #e2e8f0; background: #f1f5f9; }
.attr-tbl td { padding: 5px 4px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
.attr-tbl tr:hover { background: #f8fafc; }
.attr-tbl .col-name { word-break: break-word; }
.attr-tbl .col-raw { font-family: monospace; font-size: 10px; }
/* Attribute explanation rows: full-width below the data row */
.attr-explain-row td { padding-top: 0 !important; }
.attr-explain-row:hover { background: transparent; }
/* Recommendations */
.rec-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px; border-radius: 6px; margin-bottom: 8px; }
.rec-icon { font-size: 18px; flex-shrink: 0; width: 24px; text-align: center; }
.rec-item strong { display: block; margin-bottom: 2px; }
.rec-item p { font-size: 12px; color: #64748b; margin: 0; }
.rec-ok { background: #dcfce7; border: 1px solid #86efac; }
.rec-ok .rec-icon { color: #16a34a; }
.rec-warn { background: #fef3c7; border: 1px solid #fcd34d; }
.rec-warn .rec-icon { color: #ca8a04; }
.rec-critical { background: #fee2e2; border: 1px solid #fca5a5; }
.rec-critical .rec-icon { color: #dc2626; }
.rec-info { background: #e0f2fe; border: 1px solid #7dd3fc; }
.rec-info .rec-icon { color: #0284c7; }
/* Footer */
.rpt-footer {
margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0;
display: flex; justify-content: space-between; font-size: 10px; color: #94a3b8;
}
/* NOTE: No mobile-specific layout overrides — print layout is always A4/desktop
regardless of the device generating the PDF. The @media print block above
handles all necessary print adjustments. */`
/**
* Additions the assessment document needs on top of the shared sheet:
* state chips, findings, evidence and a frame for diagrams. Kept apart
* from REPORT_CSS so the inherited stylesheet stays byte-identical to
* the one the other reports use.
*/
export const REPORT_CSS_AUDIT = `
.diagram { border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px;
background: #ffffff; margin: 6px 0 14px; overflow-x: auto; }
.diagram-note { font-size: 10.5px; color: #64748b; margin: 0 0 10px; }
.chip { display: inline-block; padding: 2px 9px; border-radius: 999px;
font-size: 10px; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase; white-space: nowrap; }
.chip.critical { background: #fee2e2; color: #991b1b; }
.chip.warning { background: #fef3c7; color: #92400e; }
.chip.observation { background: #dbeafe; color: #1e40af; }
.chip.conformant { background: #dcfce7; color: #166534; }
.chip.accepted { background: #e0e7ff; color: #3730a3; }
.chip.unverified, .chip.not_applicable { background: #f1f5f9; color: #475569; }
.finding { border: 1px solid #e2e8f0; border-left: 3px solid #cbd5e1;
border-radius: 6px; padding: 11px 13px; margin-bottom: 9px;
page-break-inside: avoid; break-inside: avoid; }
.finding.critical { border-left-color: #dc2626; }
.finding.warning { border-left-color: #ca8a04; }
.finding.observation { border-left-color: #3b82f6; }
.finding.conformant { border-left-color: #16a34a; }
.finding.accepted { border-left-color: #4f46e5; }
.finding-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.finding-head .title { font-weight: 700; font-size: 12.5px; color: #0f172a; }
.finding-head .cid { font-size: 10px; color: #94a3b8; font-family: ui-monospace,
SFMono-Regular, Menlo, monospace; }
.finding p { margin: 6px 0 0; font-size: 12px; color: #334155; }
.finding .rationale { font-size: 11px; color: #64748b; }
.evidence { margin-top: 8px; background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 5px; padding: 8px 10px; font-family: ui-monospace,
SFMono-Regular, Menlo, monospace; font-size: 10px; color: #475569;
white-space: pre-wrap; word-break: break-word; max-height: 260px;
overflow: hidden; }
/* The document is laid out for a page, but it is opened on phones
too. Wide content keeps its own scroller so the page itself never
moves sideways, and the header stacks instead of colliding. */
@media screen and (max-width: 640px) {
.rpt-header { flex-direction: column; align-items: flex-start; gap: 10px; }
.rpt-header-right { text-align: left; }
.attr-tbl { display: block; overflow-x: auto; white-space: nowrap; }
.attr-tbl td, .attr-tbl th { white-space: normal; }
.diagram { padding: 8px; }
.top-bar-subtitle { display: none; }
}
.evidence-block { margin-top: 8px; }
.evidence-block .attr-tbl { font-size: 10.5px; margin: 4px 0 8px; }
.evidence-title { font-size: 11px; font-weight: 700; color: #334155;
margin: 8px 0 2px; }
.evidence-list { margin: 4px 0 8px; padding-left: 18px; font-size: 10.5px;
color: #475569; }
.evidence-list li { margin-bottom: 2px; word-break: break-word; }
.evidence-excerpt-note { margin:7px 0 0 !important; padding-top:6px;
border-top:1px solid #e2e8f0; font-size:10px !important;
color:#64748b !important; }
/* The inherited title is a block; the mark sits on its baseline. */
.section-title { display: flex; align-items: center; }
.sub-title { display: flex; align-items: center; font-size: 12px;
margin: 14px 0 6px; color: #0f172a; }
.muted { color: #64748b; }
.sep { color: #94a3b8; padding: 0 6px; }
.scope { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 14px 16px; font-size: 11.5px; color: #475569; }
.scope ul { margin: 6px 0 0; padding-left: 18px; }
.audit-counters { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; }
.assessment-incomplete { font-weight:600; color:#475569; }
.coverage-panel { border:1px solid #dbeafe; border-radius:8px; padding:14px; margin-bottom:14px; background:#f8fafc; }
.coverage-panel h3 { font-size:13px; margin-bottom:10px; }
.audit-meter { height:9px; background:#e2e8f0; border-radius:5px; overflow:hidden; margin:8px 0; }
.audit-meter > span { display:block; height:100%; background:#3b82f6; }
.coverage-labels { display:flex; justify-content:space-between; gap:15px; font-size:11px; margin-bottom:8px; }
.capacity-item { display:grid; grid-template-columns:1fr 1fr; gap:4px 15px; margin:10px 0; font-size:11px; break-inside:avoid; }
.capacity-item > span { text-align:right; }
.capacity-item .audit-meter { grid-column:1 / -1; }
.technical-ref { font-size:10px !important; }
.technical-entry { margin-bottom:18px; }
.technical-entry > .sub-title { break-after:avoid-page; page-break-after:avoid; }
.audit-table-scroll { max-width:100%; min-width:0; overflow-x:auto; }
.evidence-record { margin:8px 0 16px; }
.evidence-record td:first-child { width:28%; color:#64748b; }
.evidence-record td { overflow-wrap:anywhere; }
.evidence-block .attr-tbl { table-layout:fixed; width:100%; }
.evidence-block .attr-tbl td, .evidence-block .attr-tbl th { overflow-wrap:anywhere; word-break:normal; }
.finding .attr-tbl { font-size:11px; }
.finding .attr-tbl td { overflow-wrap:anywhere; }
.finding .sub-title { break-after:avoid; }
.health-ring .health-icon svg { margin-right:0 !important; }
.audit-verification-ring { position:relative; width:126px; height:126px; flex:0 0 126px; color:#64748b; }
.audit-verification-ring > svg { display:block; width:100%; height:100%; }
.audit-verification-value { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; color:inherit; }
.audit-verification-value strong { font-size:25px; line-height:1.3; }
.audit-verification-value span { font-size:11px; max-width:100px; overflow-wrap:anywhere; }
.audit-result-heading { display:flex; align-items:center; gap:8px; }
.audit-result-heading svg { flex-shrink:0; }
a { color:#2563eb; text-decoration:none; }
@media print {
.audit-table-scroll { overflow:visible; }
.audit-verification-ring, .exec-text p.muted { color:#374151; }
.section, .finding { break-inside:auto; page-break-inside:auto; }
.finding-short { break-inside:avoid-page; page-break-inside:avoid; }
.section-title, .sub-title, .finding-head { break-after:avoid-page; page-break-after:avoid; }
.finding-head + p { break-after:avoid-page; }
.attr-tbl { overflow:visible !important; }
.attr-tbl thead { display:table-header-group; }
.attr-tbl tr { break-inside:avoid-page; page-break-inside:avoid; }
.technical-entry p, .finding p { orphans:3; widows:3; }
.audit-counters, .coverage-panel { break-inside:avoid; }
.diagram { break-inside: avoid; page-break-inside: avoid; }
.chip, .finding { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.evidence { max-height: none; }
}
`
/** Report identifiers follow the family format: prefix and a base-36 stamp. */
export function reportId(prefix: string): string {
return `${prefix}-${Date.now().toString(36).toUpperCase()}`
}
export function esc(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
/** Icon-only actions, as in the rest of the family: the browser's print
* dialog exposes "Save as PDF" as a destination, so one button covers both. */
const PRINT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`
export interface ShellOptions {
title: string
subtitle: string
/** Right-hand header rows, rendered in order. */
meta: Array<[string, string]>
reportId: string
logoUrl: string
topBarSubtitle?: string
footerLeft: string
footerRight: string
lang: string
body: string
/** Extra stylesheet appended after the shared one. */
extraCss?: string
}
export function renderReport(o: ShellOptions): string {
const metaRows = o.meta
.filter(([, v]) => v)
.map(([k, v]) => `<div>${esc(k)}: ${esc(v)}</div>`).join("\n")
return `<!doctype html>
<html lang="${esc(o.lang)}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(o.title)}${o.topBarSubtitle ? ` - ${esc(o.topBarSubtitle)}` : ""}</title>
<style>${REPORT_CSS}${o.extraCss || ""}</style>
</head>
<body>
<script>
function pmxPrint(){ try { window.print(); } catch(e) {} }
</script>
<div class="top-bar no-print">
<div class="top-bar-left">
<strong class="top-bar-title">${esc(o.title)}</strong>
<span class="top-bar-subtitle">${esc(o.topBarSubtitle || "")}</span>
</div>
<div class="btn-group">
<button onclick="pmxPrint()" title="Save as PDF" aria-label="Save as PDF">${PRINT_ICON}</button>
</div>
</div>
<div class="rpt-header">
<div class="rpt-header-left">
<img src="${esc(o.logoUrl)}" alt="ProxMenux" onerror="this.style.display='none'">
<div>
<h1>${esc(o.title)}</h1>
<p>${esc(o.subtitle)}</p>
</div>
</div>
<div class="rpt-header-right">
${metaRows}
<div class="rid">ID: ${esc(o.reportId)}</div>
</div>
</div>
${o.body}
<div class="rpt-footer">
<span>${esc(o.footerLeft)}</span>
<span>${esc(o.footerRight)}</span>
</div>
</body>
</html>`
}
/**
* Section marks.
*
* A document of twelve sections is navigated by flicking through it, and
* a shape is found faster than a word is read. Drawn in the title's own
* grey at a single stroke weight so they mark the section without
* competing with the states, which are the only colour that carries
* meaning here.
*/
const ICON_PATHS: Record<string, string> = {
summary: '<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="m9 14 2 2 4-4"/>',
node: '<rect x="2" y="4" width="20" height="7" rx="2"/><rect x="2" y="13" width="20" height="7" rx="2"/><path d="M6 8h.01M6 17h.01"/>',
cluster: '<circle cx="12" cy="5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/><path d="M12 7.5v4M12 11.5H6.5a1.5 1.5 0 0 0-1.5 1.5v3.5M12 11.5h5.5a1.5 1.5 0 0 1 1.5 1.5v3.5"/>',
architecture: '<rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M10 2v3M14 2v3M10 19v3M14 19v3M2 10h3M2 14h3M19 10h3M19 14h3"/>',
disks: '<rect x="2" y="4" width="20" height="7" rx="2"/><rect x="2" y="13" width="20" height="7" rx="2"/><path d="M17 7.5h.01M17 16.5h.01"/>',
network: '<rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2h14v2"/>',
storage: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
guests: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
passthrough: '<path d="M9 2v6M15 2v6"/><path d="M6 8h12v3a6 6 0 0 1-6 6 6 6 0 0 1-6-6V8Z"/><path d="M12 17v5"/>',
software: '<path d="m12 2 8 4.5v9L12 20l-8-4.5v-9L12 2Z"/><path d="M12 20v-9M4 6.5l8 4.5 8-4.5"/>',
findings: '<path d="m3 6 2 2 3-3M3 13l2 2 3-3M3 20l2 2 3-3"/><path d="M12 7h9M12 14h9M12 21h9"/>',
scope: '<circle cx="12" cy="12" r="9.5"/><path d="M12 16v-5M12 8h.01"/>',
memory: '<rect x="3" y="7" width="18" height="10" rx="1.5"/><path d="M7 17v3M12 17v3M17 17v3M6 11h2M11 11h2M16 11h2"/>',
controller: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>',
adapter: '<rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 12h.01M10 12h.01M14 12h.01"/><path d="M18 8V5M18 19v-3"/>',
bridge: '<path d="M2 17V9a10 10 0 0 1 20 0v8"/><path d="M2 13h20M7 13v4M12 13v4M17 13v4"/>',
observation: '<path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/>',
latency: '<path d="M3 18h4l3-11 4 16 3-9h4"/>',
}
/** An inline mark, sized to sit on the line of the text it precedes. */
export function icon(name: keyof typeof ICON_PATHS | string, size = 16,
color = "#64748b"): string {
const path = ICON_PATHS[name]
if (!path) return ""
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none"
stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" focusable="false"
style="flex:none;vertical-align:-2px;margin-right:8px">${path}</svg>`
}
export function section(index: number, title: string, body: string,
mark?: string): string {
return `<div class="section">
<div class="section-title">${mark ? icon(mark) : ""}${index}. ${esc(title)}</div>
${body}
</div>`
}
/** A heading inside a section, carrying its own mark. */
export function heading(title: string, mark?: string, note?: string): string {
return `<h3 class="sub-title">${mark ? icon(mark, 14) : ""}${esc(title)}${
note ? `<span class="muted" style="font-weight:400"> — ${esc(note)}</span>` : ""}</h3>`
}
export function card(label: string, value: string, opts: { center?: boolean; color?: string } = {}): string {
const cls = opts.center ? "card card-c" : "card"
const style = opts.color ? ` style="color:${opts.color}"` : ""
return `<div class="${cls}">
<div class="card-label">${esc(label)}</div>
<div class="card-value"${style}>${value}</div>
</div>`
}
export function grid(columns: 2 | 3 | 4, cards: string[]): string {
return `<div class="grid-${columns}">${cards.join("")}</div>`
}
/** Callout in the family's four tones: ok, warn, critical, info. */
export function callout(tone: "ok" | "warn" | "critical" | "info",
title: string, body: string): string {
const icon = { ok: "&#10003;", warn: "&#9888;", critical: "&#10007;", info: "&#9432;" }[tone]
return `<div class="rec-item rec-${tone}">
<div class="rec-icon">${icon}</div>
<div><strong>${esc(title)}</strong><p>${body}</p></div>
</div>`
}
export function table(headers: string[], rows: string[][]): string {
// No headers means the first column labels the second: a record read
// down rather than across.
const head = headers.length
? `<thead><tr>${headers.map((h) => `<th>${esc(h)}</th>`).join("")}</tr></thead>`
: ""
return `<table class="attr-tbl">${head}
<tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")}</tbody>
</table>`
}
/**
* Opens the report window on the click itself, before any data is
* fetched, so the popup blocker sees the user gesture. The spinner is
* what the reader looks at while the document is composed.
*/
export function openReportWindow(loadingText: string): Window | null {
const w = window.open("about:blank", "_blank")
if (w) {
w.document.write(`<html><body style="background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><div style="border:3px solid transparent;border-top-color:#06b6d4;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:0 auto"></div><p style="margin-top:16px">${esc(loadingText)}</p><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div></body></html>`)
}
return w
}
/**
* Hands the composed document to the window that was opened on the click.
*
* The window is *navigated* to the document rather than written into.
* Writing into an about:blank window leaves it, as far as the browser is
* concerned, still on about:blank — no navigation happened — and an
* installed web app then shows none of its own chrome, so on a phone the
* report opens with no way back to the page that launched it. Navigating
* to a blob URL is a real navigation, and the app supplies its close and
* back controls exactly as it does for the other reports.
*/
export function writeReport(target: Window | null, html: string): void {
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }))
if (target && !target.closed) {
target.location.href = url
return
}
// The window was blocked or the reader closed it while the document
// was being composed.
window.open(url, "_blank")
}