Assessor_2.0/src/compliance/ui/ComplianceOverview.tsx

289 lines
19 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 { useState, type ReactNode } from 'react';
import { DonutSmallArcMarker, donutArcPath, donutPercentageLabel } from '../../components/DonutMetricCard/donut-arc';
import { DashboardGlyph } from '../../features/dashboard/ClientTileParts';
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
import { ISO_MAPPING_FAILED, ISO_MAPPING_NO_BSI, type ComplianceMappingState } from './compliance-work-facets';
import { bsiRequirementFindings, type BsiFootprint, type BsiOverview } from '../bsi-grundschutz';
import {
ANNEX_A_CONTROL_COUNTS, ANNEX_A_THEME_CLAUSES,
type RegulatoryControlOverviewItem, type RegulatoryControlTheme, type RegulatoryMappingCoverage
} from '../regulatory-mapping';
export type ComplianceFramework = 'iso' | 'bsi';
// People and Physical share one column, so the footprint stays three columns wide.
const COLUMNS: RegulatoryControlTheme[][] = [['Organizational'], ['People', 'Physical'], ['Technological']];
export const COVERAGE_TILE_TITLE = 'Mapping coverage';
export const FOOTPRINT_TILE_TITLE = 'Annex A footprint';
export const BSI_FOOTPRINT_TILE_TITLE = 'Kernel footprint';
export const BSI_FOOTPRINT_WINDOW_TITLE = 'BSI Grundschutz++ Kernel footprint';
/** Practices the BSI footprint tile shows (the one with the most findings); `Show all` shows every practice. */
const BSI_TILE_PRACTICES = 1;
const FRAMEWORKS: Array<[ComplianceFramework, string]> = [['iso', 'ISO 27001/27002'], ['bsi', 'BSI Grundschutz++']];
const number = (value: number) => value.toLocaleString('en-US');
const findingCount = (value: number) => `${number(value)} finding${value === 1 ? '' : 's'}`;
/** Shading steps of the footprint; a control or requirement without findings stays unshaded. */
export const footprintStep = (findings: number) => findings >= 25 ? 4 : findings >= 10 ? 3 : findings >= 3 ? 2 : findings >= 1 ? 1 : 0;
/** Switches the overview row between the frameworks; the framework tiles below stay as they are. */
export function ComplianceFrameworkSwitch({ value, onChange }: { value: ComplianceFramework; onChange(value: ComplianceFramework): void }) {
// The Client Info tab buttons: the approved selection control of this view.
return <div className="client-info-tabs compliance-framework-switch" role="group" aria-label="Compliance overview framework">
{FRAMEWORKS.map(([id, label]) => <button key={id} type="button" data-framework-view={id}
aria-pressed={value === id} onClick={() => onChange(id)}>{label}</button>)}
</div>;
}
interface Props {
framework: ComplianceFramework;
/** Undefined while the overview loads: values stay an em dash and nothing opens Work. */
controls?: RegulatoryControlOverviewItem[];
coverage?: RegulatoryMappingCoverage;
bsi?: BsiOverview;
bsiFootprint?: BsiFootprint;
/** Annex A titles of the active corpus; a control without findings is named by these. */
annexATitles?: Record<string, string>;
onOpenControl(controlId: string): void;
onOpenTheme(theme: RegulatoryControlTheme): void;
onOpenBsiRequirement(requirementId: string): void;
onOpenBsiPractice(practiceId: string): void;
/** Opens Work with the deviations in this `ISO mapping` state (failed, to map them again, or without a BSI requirement). */
onOpenMappingState(state: ComplianceMappingState): void;
}
/**
* Client Info `Compliance` overview row above the framework tiles, for the framework chosen by the switch: how far
* the timeslot's deviations are mapped, and where they land in the catalog. It states where findings land,
* never whether a control or requirement is met.
*/
export function ComplianceOverview(props: Props) {
const { framework, controls, coverage, bsi, onOpenMappingState } = props;
const ready = controls && coverage ? coverage : undefined;
if (framework === 'iso') {
return <div className="compliance-overview" aria-label="Compliance overview" data-framework-view="iso">
<CoverageTile caption="Deviations with an ISO control" centre={ready?.accepted} total={ready?.deviations} states={[
{ key: 'accepted', label: 'Accepted ISO control', value: ready?.accepted, arc: true },
{ key: 'mappedNoneAccepted', label: 'Mapped, none accepted', value: ready?.mappedNoneAccepted, arc: true },
{ key: 'notMapped', label: 'Not mapped yet', value: ready?.notMapped },
{ key: 'failed', label: 'Mapping failed', value: ready?.failed, arc: true, onOpen: () => onOpenMappingState(ISO_MAPPING_FAILED) }]}/>
<IsoFootprint {...props}/>
</div>;
}
// A finding with a derived requirement also has an accepted ISO control; the rest of those keep only ISO.
const withBsi = ready && controls
? new Set([...bsiRequirementFindings(controls, bsi).values()].flat().map(finding => finding.resultProjectionId)).size : undefined;
return <div className="compliance-overview" aria-label="Compliance overview" data-framework-view="bsi">
<CoverageTile caption="Deviations with a BSI requirement" centre={withBsi} total={ready?.deviations} states={[
{ key: 'bsi', label: 'With BSI requirement', value: withBsi, arc: true },
{ key: 'isoOnly', label: 'ISO control, no BSI requirement', value: ready && withBsi !== undefined ? ready.accepted - withBsi : undefined, arc: true,
onOpen: () => onOpenMappingState(ISO_MAPPING_NO_BSI) },
{ key: 'mappedNoneAccepted', label: 'Mapped, none accepted', value: ready?.mappedNoneAccepted, arc: true },
{ key: 'notMapped', label: 'Not mapped yet', value: ready?.notMapped },
{ key: 'failed', label: 'Mapping failed', value: ready?.failed, arc: true, onOpen: () => onOpenMappingState(ISO_MAPPING_FAILED) }]}/>
<BsiFootprintTile {...props}/>
</div>;
}
interface CoverageState { key: string; label: string; value?: number; arc?: boolean; onOpen?(): void }
/** The shared Client Info donut; states without an arc are the uncovered remainder drawn by the neutral track. */
function CoverageTile({ caption, centre, total, states }: { caption: string; centre?: number; total?: number; states: CoverageState[] }) {
const share = (value: number) => total ? value / total * 100 : 0;
let offset = 0;
const arcs = states.filter(state => state.arc && state.value).map(state => {
const percentage = share(state.value!);
const arcOffset = offset;
offset += percentage;
return <g key={state.key} data-state={state.key}><path className="client-info-donut-segment" d={donutArcPath(percentage, arcOffset)}/>
<DonutSmallArcMarker percentage={percentage} offset={arcOffset} className="client-info-donut-marker"/></g>;
});
const shown = (value?: number) => value === undefined ? '—' : number(value);
return <section className="client-env-group compliance-coverage" aria-labelledby="compliance-coverage-title">
<header className="client-env-group-header">
<h3 className="client-env-group-title" id="compliance-coverage-title"><DashboardGlyph name="chart"/><span>{COVERAGE_TILE_TITLE}</span></h3>
</header>
<div className="compliance-coverage__body">
<div className="compliance-coverage__ring">
<div className="client-info-donut" role="img" aria-label={`${caption}: ${shown(centre)} of ${shown(total)}`}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="client-info-donut-track" cx="60" cy="60" r="48" pathLength="100"/>
{arcs}
</svg>
<span><strong>{shown(centre)}</strong><small>{centre !== undefined && total ? donutPercentageLabel(share(centre)) : '—'}</small></span>
</div>
<span className="compliance-coverage__caption">{caption}</span>
</div>
<dl className="compliance-coverage__legend" aria-label="Mappable deviations of this timeslot by mapping state">
{states.map(state => {
// A state with an action and findings is one click target over its whole row, like an ISO tile theme.
const actionable = Boolean(state.onOpen && state.value);
return <div key={state.key} data-state={state.key} className={actionable ? 'is-actionable' : undefined}>
<i aria-hidden="true"/><dt>{state.label}</dt>
<dd>{actionable
? <button type="button" aria-label={`Open ${state.label} findings in Work (${state.value})`} onClick={state.onOpen}>{shown(state.value)}</button>
: shown(state.value)}</dd>
</div>;
})}
<div className="is-total"><i aria-hidden="true"/><dt>Mappable deviations</dt><dd>{shown(total)}</dd></div>
</dl>
</div>
<p className="compliance-coverage__note">Shows how far mapping has progressed, not whether controls are met.</p>
</section>;
}
interface FootprintDetail { id: string; group: string; title?: string; via?: string; findings: number; reachable: boolean }
/** Shared footprint frame: hover or focus names a cell in the detail line below the grid. */
function FootprintFrame({ title, glyph, action, children }: { title: string; glyph: 'shield' | 'policy'; action?: ReactNode; children: ReactNode }) {
return <section className="client-env-group compliance-footprint" aria-labelledby="compliance-footprint-title">
<header className="client-env-group-header">
<h3 className="client-env-group-title" id="compliance-footprint-title"><DashboardGlyph name={glyph}/><span>{title}</span></h3>
{action}
</header>
{children}
</section>;
}
/** Hover or focus names a cell here, below the grid. */
function FootprintDetailLine({ current }: { current?: FootprintDetail }) {
return <div className="compliance-footprint__detail" aria-live="polite">
{current
? <><div><div className="compliance-footprint__detail-id">{[current.id, current.group, current.via].filter(Boolean).join(' · ')}</div>
{current.title && <div className="compliance-footprint__detail-title">{current.title}</div>}</div>
<div className="compliance-footprint__detail-count">{current.findings > 0 ? findingCount(current.findings)
: current.reachable ? 'No mapped findings' : 'Not reachable through the ISO mapping'}</div></>
: <div className="compliance-footprint__detail-hint">Hover or focus a cell to see its title and findings.</div>}
</div>;
}
function FootprintLegend({ empty, unreachable }: { empty?: string; unreachable?: string }) {
return <div className="compliance-footprint__legend" aria-hidden="true">
{empty && <span><i className="compliance-footprint__cell"/>{empty}</span>}
{unreachable && <span><i className="compliance-footprint__cell is-unreachable"/>{unreachable}</span>}
{(['1–2', '3–9', '10–24', '25+ findings'] as const).map((label, index) =>
<span key={label}><i className="compliance-footprint__cell" data-step={index + 1}/>{label}</span>)}
</div>;
}
function IsoFootprint({ controls, annexATitles, onOpenControl, onOpenTheme }: Props) {
const [current, setCurrent] = useState<FootprintDetail>();
const byId = new Map((controls ?? []).map(control => [control.controlId, control]));
const theme = (name: RegulatoryControlTheme) => {
const cells = Array.from({ length: ANNEX_A_CONTROL_COUNTS[name] }, (_, index) => {
const controlId = `${ANNEX_A_THEME_CLAUSES[name]}.${index + 1}`;
return { controlId, control: byId.get(controlId) };
});
const hits = cells.filter(cell => cell.control?.findings.length);
// A finding with several controls of one theme counts once for that theme.
const findings = new Set(hits.flatMap(cell => cell.control!.findings.map(finding => finding.resultProjectionId))).size;
const summary = <><span className="compliance-footprint__theme-name">{name}</span>
<span className="compliance-footprint__theme-meta">{controls
? `${number(hits.length)} of ${number(cells.length)} controls · ${findingCount(findings)}` : '—'}</span></>;
return <div key={name} className="compliance-footprint__theme" data-iso-theme={name}>
{/* Like the ISO tile, only a theme with hits opens Work: the ISO theme facet offers only values with hits. */}
{findings > 0
? <button type="button" className="compliance-footprint__theme-head" aria-label={`Open ${name} ISO theme findings in Work (${findings})`}
onClick={() => onOpenTheme(name)}>{summary}</button>
: <div className="compliance-footprint__theme-head">{summary}</div>}
<div className="compliance-footprint__cells">{cells.map(({ controlId, control }) => {
const count = control?.findings.length ?? 0;
const className = `compliance-footprint__cell${current?.id === `A.${controlId}` ? ' is-current' : ''}`;
const show = () => setCurrent({ id: `A.${controlId}`, group: name, title: control?.title ?? annexATitles?.[controlId], findings: count, reachable: true });
return count > 0
? <button key={controlId} type="button" className={className} data-control={controlId} data-step={footprintStep(count)}
aria-label={`Open A.${controlId}${control?.title ? ` ${control.title}` : ''} findings in Work (${count})`}
onMouseEnter={show} onFocus={show} onClick={() => onOpenControl(controlId)}>{controlId}</button>
: <span key={controlId} className={className} data-control={controlId} aria-hidden="true" onMouseEnter={show}>{controlId}</span>;
})}</div>
</div>;
};
return <FootprintFrame title={FOOTPRINT_TILE_TITLE} glyph="shield">
<div className="compliance-footprint__themes">
{COLUMNS.map(column => <div key={column.join()} className="compliance-footprint__column">{column.map(theme)}</div>)}
</div>
<FootprintDetailLine current={current}/>
<FootprintLegend empty="No mapped findings (not a compliance statement)"/>
</FootprintFrame>;
}
interface BsiGridProps {
practices: BsiFootprint['practices'];
findingsOf: Map<string, Array<{ resultProjectionId: string }>>;
derived: Map<string, BsiOverview['requirements'][number]>;
onOpenRequirement(requirementId: string): void;
onOpenPractice(practiceId: string): void;
}
/**
* BSI Kernel requirements by practice in catalog order, each a square labelled with its number inside the practice.
* `hits` (the tile) shows the practice with the most findings; `all` (the Show all window) shows every practice, larger. A
* requirement the BSI mapping cannot reach from any ISO control is marked, so an empty square never reads as a gap of this client.
*/
function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequirement, onOpenPractice }: BsiGridProps & { mode: 'hits' | 'all' }) {
const [current, setCurrent] = useState<FootprintDetail>();
const blocks = practices.map(practice => {
const findings = new Set<string>();
const hits = practice.requirements.filter(requirement => {
const list = findingsOf.get(requirement.id) ?? [];
for (const finding of list) findings.add(finding.resultProjectionId);
return list.length > 0;
});
return { practice, findings: findings.size, cells: practice.requirements, hits: hits.length };
});
// The tile keeps the practice with the most findings; a tie keeps catalog order.
if (mode === 'hits') blocks.splice(0, blocks.length, ...blocks.filter(block => block.hits > 0)
.sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES));
if (!blocks.length) return <p className="compliance-footprint__pending">No BSI requirements derived from accepted ISO controls in this timeslot.</p>;
return <>
<div className={`compliance-footprint__practices${mode === 'all' ? ' is-window' : ''}`}>
{blocks.map(({ practice, findings, cells, hits }) => {
const summary = <><span className="compliance-footprint__theme-name">{practice.id} · {practice.title}</span>
<span className="compliance-footprint__theme-meta">{`${number(hits)} of ${number(practice.requirements.length)} requirements · ${findingCount(findings)}`}</span></>;
return <div key={practice.id} className="compliance-footprint__theme" data-bsi-practice={practice.id}>
{findings > 0
? <button type="button" className="compliance-footprint__theme-head" aria-label={`Open BSI practice ${practice.id} findings in Work (${findings})`}
onClick={() => onOpenPractice(practice.id)}>{summary}</button>
: <div className="compliance-footprint__theme-head">{summary}</div>}
<div className="compliance-footprint__requirements">{cells.map(requirement => {
const count = findingsOf.get(requirement.id)?.length ?? 0;
const known = derived.get(requirement.id);
const className = `compliance-footprint__cell${requirement.reachable ? '' : ' is-unreachable'}${current?.id === requirement.id ? ' is-current' : ''}`;
const show = () => setCurrent({ id: requirement.id, group: `${practice.id} ${practice.title}`, title: known?.title ?? requirement.title,
via: known ? `via ${known.via.map(via => `A.${via.isoControlId}`).join(', ')}` : undefined, findings: count, reachable: requirement.reachable });
// The practice heading names the prefix, so a square shows only the number, like an Annex A cell.
const label = requirement.id.slice(practice.id.length + 1);
return count > 0
? <button key={requirement.id} type="button" className={className} data-requirement={requirement.id} data-step={footprintStep(count)}
aria-label={`Open ${requirement.id}${known ? ` ${known.title}` : ''} findings in Work (${count})`}
onMouseEnter={show} onFocus={show} onClick={() => onOpenRequirement(requirement.id)}>{label}</button>
: <span key={requirement.id} className={className} data-requirement={requirement.id} aria-hidden="true" onMouseEnter={show}>{label}</span>;
})}</div>
</div>;
})}
</div>
{/* In the Show all window this stays pinned below the scrolling grid, so a hovered square is always named. */}
<div className="compliance-footprint__footer">
<FootprintDetailLine current={current}/>
<FootprintLegend empty="Reachable, no mapped findings" unreachable="Not reachable through the ISO mapping"/>
</div>
</>;
}
/** The tile shows the practice with the most findings; `Show all` opens every practice in a window. */
function BsiFootprintTile({ controls, bsi, bsiFootprint, onOpenBsiRequirement, onOpenBsiPractice }: Props) {
const findingsOf = controls ? bsiRequirementFindings(controls, bsi) : new Map<string, RegulatoryControlOverviewItem['findings']>();
const derived = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement]));
const grid = { findingsOf, derived };
const ready = controls && bsiFootprint;
return <FootprintFrame title={BSI_FOOTPRINT_TILE_TITLE} glyph="policy" action={ready && <EnvironmentResultsButton title={BSI_FOOTPRINT_WINDOW_TITLE} trigger="Show all">
{dismiss => <div className="compliance-footprint compliance-footprint--window">
<BsiRequirementGrid mode="all" practices={bsiFootprint.practices} {...grid}
onOpenRequirement={id => { dismiss(); onOpenBsiRequirement(id); }} onOpenPractice={id => { dismiss(); onOpenBsiPractice(id); }}/>
</div>}
</EnvironmentResultsButton>}>
{ready
? <BsiRequirementGrid mode="hits" practices={bsiFootprint.practices} {...grid} onOpenRequirement={onOpenBsiRequirement} onOpenPractice={onOpenBsiPractice}/>
: <p className="compliance-footprint__pending">—</p>}
</FootprintFrame>;
}