/** * 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 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 affected: Array>; 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 = { 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 ` ${esc(label)}` } 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 = {} 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 ? `

${esc(block.title)}

` : "" 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 => `
` + table([], block.columns.map((column, i) => [esc(column), esc(row[i])])) + `
`).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]) => [`${esc(k)}`, 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 ? `
    ${lines.map((l) => `
  • ${esc(l)}
  • `).join("")}
` : "") }) const notice = omitted && compact ? `

${esc(auditLabel(compact.t, "evidenceExcerpt"))}

` : "" return `
${parts.join("")}${notice}
` } // --------------------------------------------------------------------------- // Assessment summary // --------------------------------------------------------------------------- function executiveSummary(input: DocumentInput, n: number): string { const { findings, t, locale } = input const counts: Record = {} 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> = {} 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) => ` ${esc(t(`audit.classifications.${s}`))}`).join("") const body = `
${coverageValue} ${esc(auditLabel(t, "verified"))}

${icon("summary", 22, "#64748b")}${esc(t("audit.document.verdictHeading"))}

${!findings.length ? `

${esc(t("audit.document.verdictText.none"))}

` : !applicable.length ? `

${esc(auditLabel(t, "noApplicable"))}

` : ""} ${incomplete ? `

${esc(auditLabel(t, "incomplete"))}

` : ""}

${esc(auditLabel(t, "verificationScope"))}

${esc(t("audit.document.runAt", { date: when(input.run?.started_at, locale) }))}

${[ 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("")}
${chart ? `

${esc(t("audit.document.chartNote"))}

${chart}
${legend}
` : ""}` 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)}`)), `${esc(t(`audit.checks.${f.check_id}.title`))}`, 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 `
` + heading(`${title} · ${checks.length}`, "summary") + table( [auditLabel(t, "checkName"), t("audit.document.area"), auditLabel(t, "result")], sorted.map(f => [ `${esc(t(`audit.checks.${f.check_id}.title`))}`, esc(t(`audit.areas.${f.area}`)), chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)), ])) + `
` } 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 ? ` (${esc(t("audit.document.thisNode"))})` : ""), 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 ? `

${esc(t("audit.document.clusterDiagramNote"))}

${diagram}
` : ""} ${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) => [ `${esc(c.slot)}`, 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 ? `

${esc(t("audit.document.architectureNote"))}

${diagram}
` : ""} ${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 [ `${esc(d.name)}`, esc(d.model || "—"), `${esc(d.serial || "—")}`, 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)) : ``, ] }) // 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)), `${esc(o.message || "")}`, ]) 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 ? `

${esc(t("audit.document.observationsNote"))}

${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 `${esc(t("audit.inventory.unresolved"))}` if (hops.length === 0) return `${esc(t("audit.inventory.noUplink"))}` return hops.map((h) => esc(h.id + (h.mode ? ` · ${h.mode}` : ""))) .join('') } 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) => [ `${esc(a.name)}`, 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`) : "—", `${esc(a.mac || "—")}`, esc(a.driver || "—"), `${esc(a.pci || "—")}`, ]) const bridgeRows = Object.entries(net?.bridges || {}).map(([id, b]: [string, any]) => [ `${esc(id)}`, chain(b.uplink ?? null, t), esc(String(guests.filter((g: any) => (g.interfaces || []).some((i: any) => i.bridge === id)).length)), ]) const body = ` ${diagram ? `

${esc(t("audit.document.networkDiagramNote"))}

${diagram}
` : ""} ${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) => [ `${esc(t(`audit.document.target.${target.target}`))}`, 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 ? `

${esc(t("audit.document.latencyNote"))}

${chart}
` : ""} ${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) => [ `${esc(st.id)}`, esc(st.type), esc(st.content || "—"), st.shared ? chip("pass", t("audit.document.shared")) : ``, 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 `
${esc(r.storage)}${esc(bytes(Number(r.used)))} / ${esc(bytes(Number(r.total)))}
` }).join("") const body = ` ${guests.length ? `

${icon("storage")}${esc(auditLabel(t,"coverage"))}

${selected} / ${guests.length} · ${esc(auditLabel(t,"scheduled"))}${unprotected.length} · ${esc(auditLabel(t,"noJob"))}

${esc(auditLabel(t,"copyScope"))}

` : ""} ${diagram ? `

${esc(t("audit.document.storageDiagramNote"))}

${diagram}
` : ""} ${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) => [ `${esc(String(g.vmid))}`, 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 || "—"), `${esc(d.address || "—")}`, esc((d.iommu_groups || []).join(", ") || "—"), (d.shared_group_devices || []).length ? chip("warn", String(d.shared_group_devices.length)) : ``, ]) 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 = [ `
`, chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)), `${esc(t(`audit.checks.${f.check_id}.title`))}`, f.incomplete ? chip("unknown", t("audit.document.incomplete")) : "", `
`, ] const summary = summaryOf(f, t, true) if (summary) bits.push(`

${esc(summary)}

`) // "Could not be evaluated" describes the assessment, not the host. const unread = unreadSources(f.sources, t) if (unread) bits.push(`

${esc(unread)}

`) bits.push(`

${esc(t(`audit.checks.${f.check_id}.rationale`))}

`) if (f.exception) { bits.push(`

${esc(t("audit.detail.acceptedRisk"))}: ` + `${esc(f.exception.reason)} — ${esc(f.exception.accepted_by)}, ` + `${esc(when(f.exception.accepted_at, locale))}

`) } for (const group of groups) { bits.push(heading(group.title), group.note ? `

${esc(group.note)}

` : "", 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(`

${esc(auditLabel(t,"detailsLink"))}: ${esc(f.check_id)}

`) } const rowCount = groups.reduce((total, group) => total + group.rows.length, 0) parts.push(`
${bits.join("\n")}
`) } } 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 ? `

${esc(t("audit.document.diagnosticActing", { count: String(acting.length) }))}

` : `

${esc(t("audit.document.diagnosticClear"))}

` 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 = [ `
`, chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)), `${esc(t(`audit.checks.${f.check_id}.title`))}`, `${esc(t(`audit.areas.${f.area}`))}`, `
`, ] const summary = summaryOf(f, t) if (summary) bits.push(`

${esc(summary)}

`) const unread = unreadSources(f.sources, t) if (unread) bits.push(`

${esc(unread)}

`) bits.push(`

${esc(t(`audit.checks.${f.check_id}.rationale`))}

`) 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(`

${esc(t("audit.document.diagnosticMoreRows", { count: String(group.rows.length - shown.length) }))}

`) } } return `
${bits.join("\n")}
` }) // 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"), `

${esc(auditLabel(input.t,"annexScope"))}

` + rows.map(f => `
` + heading(input.t(`audit.checks.${f.check_id}.title`), "scope", f.check_id) + evidenceHtml(f.evidence, input.locale) + `
`).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 = `

${esc(t("audit.document.scopeText", { profile: t(`audit.profile.${input.profile}`) }))}

  • ${esc(t("audit.document.scopeLocal"))}
  • ${esc(auditLabel(t,"readOnlyScope"))}
  • ${esc(t("audit.document.scopeMoment"))}
  • ${esc(declared)}
${missing.length ? `

${esc(t("audit.document.notRead"))}

    ${missing.map(([k, v]) => `
  • ${esc(k)}: ${esc(String(v))}
  • `).join("")}
` : ""}
` 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(//g, '') // 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 }