Focus the Audit & Report tab, scope API tokens

This commit is contained in:
MacRimi
2026-09-12 00:22:14 +02:00
parent f12ca3ed27
commit 0b64549230
25 changed files with 683 additions and 146 deletions
+98 -5
View File
@@ -892,6 +892,93 @@ function scopeSection(input: DocumentInput, n: number): string {
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Focused reports: a posture header and the one panel that answers the
// report's question. A focused report opens on its verdict, not on what
// the machine is — the inventory is its own report.
// ---------------------------------------------------------------------------
/** The verdict a focused report opens on: the counts that bear on its
* question, phrased in its own terms. The counts are already scoped,
* because a focused run only ran that profile's checks. */
function postureHeader(input: DocumentInput, n: number): string {
const { findings, t, locale } = input
const counts: Record<string, number> = {}
for (const f of findings) { const c = shownAs(f); counts[c] = (counts[c] || 0) + 1 }
const fails = counts.critical || 0
const warns = counts.warning || 0
const applicable = findings.filter(f => f.classification !== "not_applicable")
const verified = applicable.filter(f => !f.incomplete &&
["critical", "warning", "observation", "conformant", "accepted"].includes(f.classification)).length
const incomplete = verified < applicable.length || !!(input.run && !input.run.finished_at)
const state = fails ? "critical" : warns ? "warning"
: counts.observation ? "observation" : "conformant"
const headline = t(`audit.document.posture.${input.profile}`, {
critical: String(fails), warning: String(warns),
observation: String(counts.observation || 0),
})
const body = `
<div class="exec-box posture-${esc(state)}">
<div class="exec-text">
<h3 class="audit-result-heading">${icon("summary", 22, CLASS_COLOR[state])}${esc(t(`audit.profile.${input.profile}`))}</h3>
<p>${esc(headline)}</p>
${incomplete ? `<p class="assessment-incomplete">${esc(auditLabel(t, "incomplete"))}</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 }),
].join("")}</div>`
return section(n, t("audit.document.postureTitle"), body, "summary")
}
/** Backup coverage: the signature panel of the backup report — how many
* guests carry a job, drawn as a meter with the guests that carry none. */
function backupCoveragePanel(input: DocumentInput, n: number): string {
const s = input.inventory?.sections || {}
const guests = s.guests || []
const { t } = input
if (!guests.length) return ""
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 diagram = storageDiagram(guests, {
guests: t("audit.inventory.guests"), storage: t("audit.document.storage"),
backup: t("audit.document.backupDestination"), unprotected: auditLabel(t, "noJob"),
})
const body = `
<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>` : ""}
${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, auditLabel(t, "coverage"), body, "storage")
}
/** Capacity meters: the signature panel of the capacity report — used
* against total per connected storage, read from the check's evidence. */
function capacityMetersPanel(input: DocumentInput, n: number): string {
const { t } = input
const finding = input.findings.find(f => f.check_id === "storage.connected_storage")
let capacityRows: any[] = []
try { capacityRows = JSON.parse(finding?.evidence || "{}").storages || [] } catch { /* Raw evidence stays in the appendix. */ }
const meters = 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("")
if (!meters) return ""
return section(n, auditLabel(t, "capacity"), meters, "storage")
}
export function buildAuditDocument(input: DocumentInput): string {
const { t, locale } = input
const node = input.inventory?.sections?.identity?.node || t("audit.document.unknownNode")
@@ -905,6 +992,10 @@ export function buildAuditDocument(input: DocumentInput): string {
// 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.
// The inventory is its own report: an assessment — the whole audit or
// a focused one — opens on its verdict and prints no structure tables.
// A focused report adds the one panel that answers its question, and
// the inventory profile is the only one that documents the machine.
const builders = input.profile === "inventory"
? [
identitySection, clusterSection, architectureSection, disksSection,
@@ -913,11 +1004,13 @@ export function buildAuditDocument(input: DocumentInput): string {
]
: input.profile === "diagnostic"
? [diagnosticSummary, actionsSection, unreadSection, scopeSection]
: [
executiveSummary, identitySection, clusterSection, architectureSection,
disksSection, networkSection, latencySection, storageSection, guestsSection,
passthroughSection, proxmenuxSection, findingsSection, scopeSection, evidenceSection,
]
: input.profile === "security"
? [postureHeader, findingsSection, scopeSection, evidenceSection]
: input.profile === "backup"
? [postureHeader, backupCoveragePanel, findingsSection, scopeSection, evidenceSection]
: input.profile === "capacity"
? [postureHeader, capacityMetersPanel, disksSection, findingsSection, scopeSection, evidenceSection]
: [executiveSummary, 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
+18 -13
View File
@@ -93,11 +93,13 @@ const DEFS = `<defs>
</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.
// A viewBox with no fixed width lets the diagram scale down to a narrow
// column, but `max-width` caps it at its own coordinate space so a
// diagram with few elements is not scaled up until its boxes and text
// fill the page. Centred, so a capped diagram sits under its heading.
return `<svg viewBox="0 0 ${width} ${height}" width="100%" role="img"
preserveAspectRatio="xMidYMin meet"
style="display:block;height:auto">${DEFS}${body}</svg>`
style="display:block;height:auto;max-width:${width}px;margin-inline:auto">${DEFS}${body}</svg>`
}
/**
@@ -113,7 +115,10 @@ export function networkDiagram(
const entries = Object.entries(bridges || {})
if (entries.length === 0) return ""
const COL_W = 132, BOX_H = 34, GAP_Y = 12, PAD = 12
// COL_W is the column pitch and BOX_W the box itself: the difference
// between them is the horizontal air between a box and the next, drawn
// as the arrow. A wider pitch spreads the columns apart.
const COL_W = 180, BOX_W = 116, 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) {
@@ -140,7 +145,7 @@ export function networkDiagram(
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
const width = PAD * 2 + COL_W * guestsCol + BOX_W
let y = PAD
const parts: string[] = []
@@ -149,7 +154,7 @@ export function networkDiagram(
? [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"
`<text x="${PAD + COL_W * i + BOX_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
@@ -160,20 +165,20 @@ export function networkDiagram(
row.nics.forEach((n, i) => {
const ny = y + i * (BOX_H + GAP_Y)
parts.push(box(PAD, ny, COL_W - 20, BOX_H, n))
parts.push(box(PAD, ny, BOX_W, 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))
parts.push(arrow(PAD + BOX_W, 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,
parts.push(box(PAD + COL_W, midY, BOX_W, BOX_H, row.bond))
parts.push(arrow(PAD + COL_W + BOX_W, 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,
parts.push(box(PAD + COL_W * bridgeCol, midY, BOX_W, BOX_H, row.bridge))
parts.push(arrow(PAD + COL_W * bridgeCol + BOX_W, 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,
parts.push(box(PAD + COL_W * guestsCol, midY, BOX_W, BOX_H,
{ id: `${row.bridge.id}-g`, label: String(row.count), sub: labels.guests }))
y += block
}
+2
View File
@@ -301,6 +301,7 @@ export function esc(value: unknown): string {
/** 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 PRINTER_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"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>`
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 {
@@ -343,6 +344,7 @@ function pmxPrint(){ try { window.print(); } catch(e) {} }
<span class="top-bar-subtitle">${esc(o.topBarSubtitle || "")}</span>
</div>
<div class="btn-group">
<button onclick="pmxPrint()" title="Print" aria-label="Print">${PRINTER_ICON}</button>
<button onclick="pmxPrint()" title="Save as PDF" aria-label="Save as PDF">${PRINT_ICON}</button>
</div>
</div>
+1 -1
View File
@@ -8,4 +8,4 @@
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
//
// Keep the three in sync on every bump.
export const APP_VERSION = "1.2.6"
export const APP_VERSION = "1.2.6.1-beta"