120 lines
13 KiB
TypeScript
120 lines
13 KiB
TypeScript
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>`;
|
||
}
|