Assessor_2.0/src/reporting/management-overview-report.ts

120 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ReportDomainSummary, ReportProjection } from './report-projection';
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
import { escapeHtml } from '../shared/html';
import { statementText } from './report-statement-text';
import { buildContextManagementHtml } from './context-management-report';
/** Offline management report: a factual view of the shared projection. */
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '3.3.0';
const DASH = '—';
const count = (n: number): string => Number.isFinite(n) ? n.toLocaleString('de-DE') : DASH;
function date(iso: string): string {
const parsed = Date.parse(iso);
return Number.isFinite(parsed)
? new Date(parsed).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: DASH;
}
function tile(item: ReportDomainSummary): string {
const assessed = item.assessed > 0 && Number.isFinite(item.assessed) && Number.isFinite(item.deviations);
const share = assessed ? Math.round(100 * item.deviations / item.assessed) : undefined;
const level = share === undefined ? 'unknown' : `h${share < 25 ? 0 : share < 50 ? 1 : share < 75 ? 2 : 3}`;
return `<li class="${level}"><span class="area">${escapeHtml(item.domainLabel)}</span>
<strong class="value">${share === undefined ? DASH : `${count(share)} %`}</strong>
<span class="count">${assessed ? `${count(item.deviations)} von ${count(item.assessed)} bewertet` : 'Keine bewerteten Ergebnisse'}</span></li>`;
}
function comparisonRow(item: ReportDomainSummary): string {
const assessed = Number.isFinite(item.assessed) && item.assessed > 0;
const passed = assessed && Number.isFinite(item.passed) ? Math.max(0, Math.min(item.passed, item.assessed)) : 0;
const deviations = assessed && Number.isFinite(item.deviations) ? Math.max(0, Math.min(item.deviations, item.assessed - passed)) : 0;
const other = assessed ? Math.max(0, item.assessed - passed - deviations) : 0;
const passPercent = assessed ? Math.round(100 * passed / item.assessed) : undefined;
return `<li class="comparison-row"><span class="comparison-label">${escapeHtml(item.domainLabel)}</span>
<span class="comparison-bar" aria-label="${assessed ? `${count(passed)} bestanden, ${count(deviations)} abweichend` : 'Keine bewerteten Ergebnisse'}">
<span class="bar-pass" style="width:${assessed ? 100 * passed / item.assessed : 0}%"></span>
<span class="bar-fail" style="width:${assessed ? 100 * deviations / item.assessed : 0}%"></span>
${other ? `<span class="bar-other" style="width:${100 * other / item.assessed}%"></span>` : ''}
</span>
<span class="comparison-value">${passPercent === undefined ? DASH : `${count(passPercent)} %`} <span>· ${assessed ? `${count(item.deviations)} von ${count(item.assessed)}` : DASH}</span></span></li>`;
}
function keyStatements(projection: ReportProjection): string {
const statements = projection.statements ?? [];
if (!statements.length) return '';
return `<section class="statements" aria-labelledby="statements-title"><h2 id="statements-title">Kernaussagen</h2><ul>${statements.map(statement => {
const { text, note } = statementText(statement);
return `<li><p>${escapeHtml(text)}</p>${note ? `<p class="statement-note">${escapeHtml(note)}</p>` : ''}</li>`;
}).join('')}</ul></section>`;
}
export function buildManagementOverviewHtml(projection: ReportProjection): string {
// Retain the historical renderer for archived projections; new management runs require Context.
if (projection.context) return buildContextManagementHtml(projection);
const domain = projection.environment.find(fact => fact.key === 'Domain')?.value;
const dc = projection.environment.find(fact => fact.key === 'Domain Controller')?.value;
const gpo = projection.gpo;
const gpoNote = gpo && projection.domains.some(item => item.domainId === 'gpo')
? `<p>Die GPO-Kachel zählt zugeordnete Ergebniszeilen. Die gesonderte GPO-Evaluation zählt ${count(gpo.counts.Fail)} abweichende Vergleiche zu ${count(gpo.affectedSettings)} Einstellungen. Diese Zähleinheiten sind nicht gleichzusetzen.${gpo.scopeLimited ? ' Die Evaluation bezieht sich auf die importierten GPO-Backups.' : ''}</p>`
: '';
return `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>ASSESSOR · Management Report · ${escapeHtml(projection.project.name)}</title>
<style>
:root{--ink:#17171c;--muted:#5c6069;--navy:#123047;--line:#cfd1d7;--paper:#fff;--surface:#f5f5f7;--orange:#df752d;--heat-0:#e7e8ec;--heat-1:#f8dfcb;--heat-2:#edb68d;--heat-3:#e68f51}
*{box-sizing:border-box}body{margin:0;background:#e7e7eb;color:var(--ink);font:400 14px/1.45 Inter,Arial,sans-serif}
.sheet{width:210mm;min-height:297mm;margin:16px auto;padding:12mm 16mm 11mm;background:var(--paper);display:flex;flex-direction:column}
.mast{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:12px;border-bottom:2px solid var(--navy)}
.brand{color:var(--navy);font-size:19px;font-weight:700;letter-spacing:.06em}.mast-note{color:var(--navy);text-align:right;font-size:12px;font-weight:600}
.kicker{margin:20px 0 7px;color:var(--orange);font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase}
h1{margin:0;color:var(--navy);font-size:30px;line-height:1.13;font-weight:600}h2{margin:0;color:var(--navy);font-size:18px;font-weight:600}
p{margin:0 0 8px}.subtitle{margin:8px 0 14px;color:var(--muted);font-size:14px}
.meta{display:flex;flex-wrap:wrap;gap:6px 18px;padding:8px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:var(--muted);font-size:14px}
.meta strong{color:var(--ink);font-weight:600}.facts{display:grid;grid-template-columns:repeat(3,1fr);margin:21px 0 26px;border-top:2px solid var(--navy);border-bottom:1px solid var(--line)}
.fact{padding:13px 15px;border-right:1px solid var(--line)}.fact:last-child{border-right:0}.fact strong{display:block;color:var(--navy);font-size:28px;line-height:1.12;font-weight:600;font-variant-numeric:tabular-nums}.fact span{display:block;margin-top:5px;font-size:14px}
.statements{margin:0 0 26px;padding:13px 15px;background:var(--surface);border-top:2px solid var(--navy);break-inside:avoid}.statements h2{font-size:16px;margin-bottom:8px}
.statements ul{margin:0;padding:0;list-style:none}.statements li{padding:8px 0;border-top:1px solid var(--line)}.statements li:first-child{border-top:0;padding-top:0}.statements li:last-child{padding-bottom:0}
.statements p{margin:0;font-size:14px}.statements .statement-note{margin-top:3px;color:var(--muted)}
.section-head{display:flex;align-items:end;justify-content:space-between;gap:15px;margin-bottom:11px}.section-head p{max-width:260px;margin:0;color:var(--muted);font-size:14px;text-align:right}
.heatmap{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin:0;padding:0;list-style:none}.heatmap li{min-height:104px;padding:11px 12px 10px;display:flex;flex-direction:column;border:1px solid rgba(18,48,71,.17);break-inside:avoid}
.heatmap .h0{background:var(--heat-0)}.heatmap .h1{background:var(--heat-1)}.heatmap .h2{background:var(--heat-2)}.heatmap .h3{background:var(--heat-3)}.heatmap .unknown{background:var(--surface)}
.heatmap .area{color:var(--navy);font-size:14px;line-height:1.25;font-weight:600}.heatmap .value{display:block;margin-top:auto;color:var(--navy);font-size:28px;line-height:1.08;font-weight:600;font-variant-numeric:tabular-nums}.heatmap .count{display:block;margin-top:3px;color:var(--ink);font-size:14px}
.legend{display:flex;align-items:center;flex-wrap:wrap;gap:5px 12px;margin:12px 0 0;color:var(--muted);font-size:14px}.legend .key{display:inline-flex;align-items:center;gap:5px;white-space:nowrap}.legend i{display:inline-block;width:14px;height:14px;border:1px solid rgba(18,48,71,.17)}
.legend .l0{background:var(--heat-0)}.legend .l1{background:var(--heat-1)}.legend .l2{background:var(--heat-2)}.legend .l3{background:var(--heat-3)}
.comparison{margin-top:26px;padding:15px 17px;background:var(--surface);border:1px solid var(--line);break-inside:avoid}.comparison h2{margin-bottom:13px}
.comparison-scale{display:grid;grid-template-columns:240px minmax(180px,1fr) 135px;gap:12px;margin-bottom:5px;color:var(--muted);font-size:12px}
.comparison-scale div{grid-column:2;display:flex;justify-content:space-between}
.comparison-list{list-style:none;margin:0;padding:0}.comparison-row{display:grid;grid-template-columns:240px minmax(180px,1fr) 135px;gap:12px;align-items:center;min-height:34px;font-size:14px}
.comparison-bar{display:flex;height:14px;background:#e3e3e8}.bar-pass{background:#167048}.bar-fail{background:#c60b41}.bar-other{background:#92969d}
.comparison-value{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap}.comparison-value span{color:var(--muted);font-weight:400}
.comparison-key{display:flex;gap:24px;margin:13px 0 0 252px;font-size:14px;color:var(--muted)}.comparison-key i{display:inline-block;width:12px;height:12px;margin-right:5px;vertical-align:-1px}.comparison-key .pass{background:#167048}.comparison-key .fail{background:#c60b41}
.notes{margin-top:23px;padding:13px 15px;background:var(--surface);border-top:2px solid var(--navy)}.notes h2{font-size:16px;margin-bottom:6px}.notes p{margin:0 0 5px;font-size:14px}.notes p:last-child{margin-bottom:0}
.foot{margin-top:auto;padding-top:10px;border-top:1px solid var(--navy);display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:12px}.foot strong{color:var(--navy);font-weight:600}
@media(max-width:800px){body{background:#fff}.sheet{width:auto;min-height:0;margin:0;padding:18px 16px 26px}.heatmap{grid-template-columns:repeat(2,1fr)}.section-head{display:block}.section-head p{max-width:none;margin-top:4px;text-align:left}h1{font-size:26px}}
@media(max-width:480px){.heatmap,.facts{grid-template-columns:1fr}.fact{border-right:0;border-bottom:1px solid var(--line)}.fact:last-child{border-bottom:0}}
@media(max-width:720px){.comparison-scale{display:none}.comparison-row{grid-template-columns:1fr auto;gap:3px 10px;margin-bottom:12px}.comparison-label{grid-column:1/-1}.comparison-bar{grid-column:1}.comparison-value{grid-column:2}.comparison-key{margin-left:0}}
@page{size:A4;margin:0}@media print{body{background:#fff;print-color-adjust:exact;-webkit-print-color-adjust:exact}.sheet{margin:0}}
</style></head><body><main class="sheet">
<header class="mast"><div class="brand">ASSESSOR</div><div class="mast-note">Management Report</div></header>
<p class="kicker">Gespeicherter Assessment-Stand</p><h1>Technische Ergebnisse im Überblick</h1>
<p class="subtitle">${escapeHtml(projection.project.name)}</p>
<div class="meta">${domain ? `<span><strong>Domain:</strong> ${escapeHtml(domain)}</span>` : ''}<span><strong>Berichtsdatum:</strong> ${date(projection.generatedAt)}</span></div>
<div class="facts" aria-label="Erfasste Kennzahlen">
<div class="fact"><strong>${dc ? escapeHtml(dc) : DASH}</strong><span>Domain Controller erfasst</span></div>
<div class="fact"><strong>${count(projection.coverage.assessed)}</strong><span>Ergebnisse bewertet</span></div>
<div class="fact"><strong>${count(projection.coverage.deviations)}</strong><span>Abweichende Ergebnisse</span></div></div>
${keyStatements(projection)}
<section aria-labelledby="heatmap-title"><div class="section-head"><h2 id="heatmap-title">Abweichungsanteil je Security Domain</h2><p>Abweichende von bewerteten Ergebnissen innerhalb des jeweiligen Bereichs.</p></div>
<ol class="heatmap" aria-label="Heatmap der bewerteten Ergebnisse">${projection.domains.length ? projection.domains.map(tile).join('') : '<li class="unknown"><span class="area">Keine Bereiche verfügbar</span><strong class="value">—</strong><span class="count">Keine bewerteten Ergebnisse</span></li>'}</ol>
<div class="legend" aria-label="Farbstufen nach Abweichungsanteil"><span>Farbstufe:</span><span class="key"><i class="l0"></i>0–24 %</span><span class="key"><i class="l1"></i>25–49 %</span><span class="key"><i class="l2"></i>50–74 %</span><span class="key"><i class="l3"></i>75–100 %</span></div></section>
<section class="comparison" aria-labelledby="comparison-title"><h2 id="comparison-title">Bestandene und abweichende Ergebnisse</h2>
<div class="comparison-scale" aria-hidden="true"><div><span>0 %</span><span>50 %</span><span>100 %</span></div></div>
<ol class="comparison-list">${projection.domains.map(comparisonRow).join('')}</ol>
<div class="comparison-key"><span><i class="pass"></i>Bestanden</span><span><i class="fail"></i>Abweichung</span></div>
<p>Pass Rate je Security Domain; rechts: Anzahl der Abweichungen von den bewerteten Ergebnissen.</p></section>
<aside class="notes" aria-labelledby="notes-title"><h2 id="notes-title">Lesart</h2><p>Die Farbe zeigt die Häufigkeit abweichender Ergebnisse innerhalb eines Bereichs. Sie zeigt weder Schweregrad noch Geschäftsrisiko. Bereiche mit wenigen Prüfungen können hohe Prozentwerte haben.</p>${gpoNote}<p>Die Ergebnisse können aus unterschiedlichen Erhebungszeitpunkten stammen. Betroffene Geschäftsdienste, Ausnutzung und aktueller Behebungsstand sind aus dieser Darstellung nicht ableitbar.</p></aside>
<footer class="foot"><span><strong>ASSESSOR ${escapeHtml(ASSESSOR_PRODUCT_VERSION)}</strong> · ${escapeHtml(projection.project.name)}</span></footer>
</main></body></html>`;
}