feat(dashboard): align metrics and risk profiles with reference layout

This commit is contained in:
Binh 2026-08-24 12:48:35 +02:00
parent 5a73e27c90
commit d74d0a5c56
6 changed files with 561 additions and 155 deletions

View File

@ -1780,6 +1780,15 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 12. Änderungsprotokoll
| Datum | Änderung |
| 2026-08-24 | Phase-7-Dashboard-IOE-Kennzahl als Filtereinstieg umgesetzt: `IOEs found` ist bei vorhandenem Trefferbestand anklickbar, setzt bestehende Dashboardfilter zurueck, uebergibt exakt die als explizite `IOE Found`/`IOEs found` gezaehlten Datensatz-IDs an die vorhandene Work-Teilmenge und oeffnet Work. Bei null Treffern bleibt das Steuerelement deaktiviert, damit kein ungefilterter Bestand erscheint. Zehn fokussierte Dashboard- und Filtertests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Ringspur nach Nutzerreferenz sichtbar gemacht: Der bislang mit dem Panel verschmelzende ungefuellte Anteil der oberen Statusringe verwendet nun den vorhandenen neutralgrauen Donut-Rahmentoken; Farbbogen, Geometrie, Werte und Interaktionen bleiben unveraendert. Sieben fokussierte Dashboardtests und vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Statusleiste nach Nutzerreferenz korrigiert: Die sechs gleichartigen Donutkarten sind durch eine fuenfteilige Leiste ersetzt. Compliance Score erhaelt den grossen Ring mit Formelhinweis; Deviation, Passed und Not Run verwenden kompakte Ringe mit realer Anzahl und Prozentwert. Not Applicable und Error stehen gemeinsam in einer Zusammenfassung neben `IOEs found`. Der IOE-Wert zaehlt ausschliesslich explizite `IOE Found`-/`IOEs found`-Quellenfelder und wird nicht aus Deviations abgeleitet; Statuskarten bleiben direkte Filtereinstiege in Work. 14 fokussierte Dashboard-, Donut- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Risk-Profile-Selektor um `Full Risk Profile` erweitert: Dieser Modus umfasst alle aktiven Findings, zeigt links statt Kategorien die real vorhandenen Werkzeuge mit ueber mehrere Zeitstaende aggregierten Findingzahlen und berechnet Severity-Ring, Marked-Zahl und Deviation-Nenner ueber den Gesamtbestand. Ein Werkzeugklick oeffnet Work mit genau diesem Quellenfilter; Werkzeugprofile behalten links ihre eigenen Kategorien. PingCastle bleibt beim ersten Oeffnen der Standard, sofern vorhanden. 18 fokussierte Dashboard-, Facetten- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-PingCastle-Risk-Profile um explizite Nullkategorien korrigiert: Die vier feste Scanner-Skala Anomalies, Stale Objects, Privileged Accounts und Trusts wird aus den strukturierten `risk.*`-Environment-Beobachtungen aufgebaut und filtert Nullwerte nicht mehr weg; `Trusts 0` bleibt damit sichtbar. Aeltere Importe ohne Risk-Observations fallen auf die werkzeugbezogenen Facettenzaehler zurueck, behalten aber alle vier Zeilen. Environment Snapshot wird fuer Directory Overview und Risk Profile gemeinsam einmal geladen. 15 fokussierte Dashboard- und Facettentests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Risk-Profile werkzeugbezogen korrigiert: Die bisherige statische Panelueberschrift ist ein klickbarer Tool-Selektor fuer PingCastle, PurpleKnight, CIS-CAT, Maester und alle weiteren real vorhandenen Scanquellen. Mehrere Zeitstaende desselben Werkzeugs werden zu genau einer Auswahl gruppiert; PingCastle ist der Standard, wenn vorhanden. Kategorien, Severity-Ring, Legendenzahlen, Marked-Zahl und Deviation-Nenner werden ausschliesslich aus den Datensaetzen des gewaehlten Werkzeugs neu gezaehlt. Kategorie- und Severity-Klicks uebergeben Werkzeug plus Auswahl gemeinsam an Work. 17 fokussierte Dashboard-, Facetten- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Unterzeile nach Nutzerreferenz korrigiert: Directory Overview zeigt vier getrennte Coverage-Kennzahlen oberhalb eines festen vierspaltigen Faktenrasters; die bisherigen Coverage-Chips und die wiederholte Summenzeile entfallen. Das rechte Panel ist nun ein Risk Profile mit `Marked for report` im Kopf, den vier staerksten realen Kategorien links sowie einem segmentierten Severity-Ring und Legende rechts. Kategorien und Severity bleiben direkte Filtereinstiege in Work; bei fehlenden Facetten erscheinen ehrliche Leerzustaende. Sieben fokussierte Dashboard-/Inset-Tests und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Scanquellen nach Nutzerkorrektur gekuerzt: Die Legende der Scan Source Distribution entfernt ausschliesslich einen abschliessenden gueltigen ISO-Tag beziehungsweise ISO-Zeitstempel aus `Werkzeug · Zeitstempel`; Werkzeugname, interner Facettenwert, Provenienz und Filter-Sprung nach Work bleiben unveraendert. Drei fokussierte Tests und Client-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard nach Nutzerreferenz als kompaktes Analyseboard neu geordnet: Der bestehende Donut-Standard zeigt Compliance Score sowie reale Statuszahlen fuer Deviation, Passed, Not Applicable, Not Run und Error; Statuskarten bleiben direkte Filtereinstiege in Work. Severity und Scanquellen erscheinen als zweispaltige segmentierte Verteilungen mit anklickbarer Legende, Directory Overview und Reportmarkierung bilden die untere Informationszeile. Der verbindliche 20-px-Panelinset und 16-px-Inhaltstext bleiben erhalten; keine erfundenen Kennzahlen, keine Import-, API-, Persistenz- oder Schemaaenderung. Neun fokussierte Layout-/Donut-/Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Gesamtlauf: 721 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; ein fachfremder CRLF-sensitiver Quelltexttest in `results-browser-timeslot-scope.test.ts` bleibt rot. Der bekannte Designcheck bleibt an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-13C-PingCastle-Environment-Subjects nachgehaertet: Profil 1.4.0, Mapping 1.2.0 und Extractor 2.1.0 verwenden normalisierte PingCastle-Berichtsnamen und Punktpfade fuer jeden sichtbaren Environment-Unterpunkt. Dazu gehoeren unter anderem `domain-information.entra-id-configuration`, die User-/Computer-Analysen, Admin-Groups-/Control-Paths-Unterpunkte, Trusts, PKI, alle Infrastructure-, Anomalies-, Password-Policies- und GPO-Unterpunkte. Leere Tabellen werden ueber Row Count/Presence, tabellenlose H2-Bereiche ueber eine quellentreue Presence-Beobachtung sichtbar; Backup, LAPS, WEF und krbtgt sind entsprechend dem Bericht unter Anomalies korrigiert. Finding-Regeldetailbereiche bleiben ausgeschlossen und unbekannte Tabellen behalten `pingcastle-table-row`. Ein ausschliesslich temporaerer, danach entfernter Read-only-Test gegen den ausdruecklich freigegebenen Dummy-Bericht bestaetigte 48 hierarchische Unterpunkt-Typen; persistierte Inhalte wurden nicht veraendert. 47 fokussierte synthetische Tests, Local-only-Sicherheitspruefung und Client-/Server-Produktionsbuild erfolgreich. Gesamtlauf: 718 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; ein fachfremder CRLF-sensitiver Quelltexttest in `results-browser-timeslot-scope.test.ts` bleibt rot. Der Designcheck bleibt an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Keine API-, SQL- oder Migrationsaenderung; bestehende PingCastle-1.3.0-Importe benoetigen einen Reimport. Phase 13C bleibt in Arbeit. |
| 2026-08-24 | Phase-7/12/13A-Reimportidentitaet fail closed nachgehaertet: Ein zielgebundener Scanner-Reimport akzeptiert nur noch das exakte SHA-256-Multiset aller Dateien des zu ersetzenden ScanRuns. Preview prueft die hochgeladenen Bytes vor Parsing und Persistenz; Complete prueft die final ausgewaehlten Vorschauobjekte erneut; Local- und PostgreSQL-Repository erzwingen die Invariante bei kanonischem Ersatz und bei der Uebernahme eines bereits importierten Ersatzlaufs. Fehlende Originalhashes, abweichende Bytes, fehlende oder zusaetzliche Dateien sowie der nicht verifizierbare Offline-Fallback werden mit eindeutiger Fehlermeldung abgelehnt; der alte Scan bleibt aktiv. Datahub bietet einen vorhandenen Ersatzlauf nur bei Hashgleichheit an, und der Reimportdialog akzeptiert keine bloss wahrscheinliche Dublette mehr. 31 fokussierte synthetische Tests und der vollstaendige Client-/Server-Produktionsbuild erfolgreich; Gesamtlauf 718 bestanden, 8 optionale PostgreSQL-Tests uebersprungen und Local-only gruen. Der Gesamtskriptstatus bleibt ausschliesslich am bekannten Designcheck fuer die unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; die Reimportaenderung erzeugt keinen neuen Designbefund. Keine Migration, keine Realdaten gelesen oder mutiert; Phasen 7, 12 und 13A bleiben abgeschlossen. |
| 2026-08-24 | Phase-7/12-Datahub-Readiness auf mandantenweite Vergleichsbereitschaft korrigiert: Die Kachel fasst nicht mehr nur den neuesten `Open`-Timeslot zusammen, sondern alle aktiven scannergebundenen Timeslots. Sobald irgendein aktiver Altstand einen Reimport auf den aktuellen ASSESSOR-Verarbeitungsvertrag benoetigt, erscheint der Mandant gelb und `Manage` dauerhaft; inkompatible oder unbekannte Vertraege bleiben rot. `Manage` klappt saemtliche betroffenen Timeslots automatisch auf, auch wenn sie weder aktuell noch ausgewaehlt sind; `Open` oeffnet weiterhin den neuesten Timeslot. Damit kann ein aktueller neuer Stand einen fuer spaetere Lifecycle-Vergleiche relevanten Altstand nicht mehr gruen ueberdecken. Zwoelf fokussierte Datahub-Tests sowie Client-Bundle und Server-Produktionsbuild erfolgreich; Gesamtlauf 715 bestanden, 8 optionale PostgreSQL-Tests uebersprungen und Local-only gruen. Der vollstaendige Client-Typecheck ist durch zwei parallele Pathfinder-Typfehler in `ObsidianGraphView.tsx` blockiert; der bekannte Designcheck bleibt nur an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Keine API-, Persistenz-, Schema- oder Realdatenaenderung; Phase 7 und 12 bleiben abgeschlossen. |

View File

@ -18,6 +18,8 @@ interface DonutMetricCardProps {
value: number;
percentage: number;
tone: DonutMetricTone;
valueSuffix?: string;
showPercentage?: boolean;
className?: string;
active?: boolean;
onClick?(): void;
@ -28,6 +30,8 @@ export function DonutMetricCard({
value,
percentage,
tone,
valueSuffix = '',
showPercentage = true,
className,
active,
onClick
@ -46,8 +50,8 @@ export function DonutMetricCard({
<circle key={label + '-' + value + '-' + boundedPercentage} className="donut-metric-card__progress" cx="100" cy="100" r="78" pathLength="100" strokeDasharray={dashArray} style={progressStyle}/>
</svg>
<div className="donut-metric-card__values">
<strong>{value.toLocaleString('en-US')}</strong>
<span>{boundedPercentage}%</span>
<strong>{value.toLocaleString('en-US')}{valueSuffix}</strong>
{showPercentage && <span>{boundedPercentage}%</span>}
</div>
</div>
</>;

View File

@ -3,72 +3,142 @@ import { AssessorApiClient } from '../../api/assessor-api-client';
import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
import { deriveDynamicFacets, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets';
import type { FindingRecord } from '../../domain/finding';
import type { ImportProfile } from '../../domain/import-profile';
import type { Review } from '../../domain/review';
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets';
const assessorApi = new AssessorApiClient();
interface DashboardPanelProps {
/** Wechselt in die Arbeitsflaeche, nachdem der Filter gesetzt wurde. */
onOpenWork(): void;
}
/**
* Die Balkenbreite ist ein Messwert und kann deshalb nicht in src/styles
* stehen. Uebergeben wird nur der Anteil, die Darstellung bleibt im CSS.
*/
function shareStyle(share: number): CSSProperties {
return { '--share': `${share}%` } as unknown as CSSProperties;
function shareStyle(value: number): CSSProperties {
return { '--share': `${value}%` } as unknown as CSSProperties;
}
function share(count: number, total: number): number {
return total > 0 ? (count / total) * 100 : 0;
}
interface DistributionProps {
eyebrow: string;
export function dashboardExplicitIoeCount(records: FindingRecord[]): number {
return dashboardExplicitIoeRecordIds(records).length;
}
export function dashboardExplicitIoeRecordIds(records: FindingRecord[]): string[] {
return records
.filter(record => record.fields.some(field => /^ioes?\s+found$/i.test(field.value.trim())))
.map(record => record.id);
}
interface DashboardStatusMetricProps {
label: string;
value: number;
percentage: number;
tone: 'all' | 'deviation' | 'passed' | 'not-run';
large?: boolean;
helper?: string;
active?: boolean;
onClick?(): void;
}
function DashboardStatusMetric({ label, value, percentage, tone, large = false, helper, active, onClick }: DashboardStatusMetricProps) {
const roundedPercentage = Math.min(100, Math.max(0, Math.round(percentage)));
const content = <>
<div className={`dashboard-status-ring dashboard-status-ring-${tone}`}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="dashboard-status-ring-track" cx="60" cy="60" r="48" pathLength="100" />
<circle className="dashboard-status-ring-progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${roundedPercentage} ${100 - roundedPercentage}`} />
</svg>
<span><strong>{roundedPercentage}</strong><small>%</small></span>
</div>
<div className="dashboard-status-copy">
<span>{label}</span>
<strong>{large ? `${roundedPercentage} %` : <>{value.toLocaleString('en-US')} <small>· {roundedPercentage}%</small></>}</strong>
{helper && <small>{helper}</small>}
</div>
</>;
const className = `dashboard-status-metric${large ? ' dashboard-status-metric-large' : ''}`;
return onClick
? <button className={className} type="button" aria-label={`${label}: ${value.toLocaleString('en-US')}, ${roundedPercentage}%`} aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
: <article className={className}>{content}</article>;
}
interface SegmentedDistributionProps {
heading: string;
question: string;
total: number;
values: DynamicFacetValue[];
severity?: boolean;
formatLabel?(label: string): string;
onSelect(value: string): void;
}
/**
* Eine Verteilung als Anteilsbalken. Jede Zeile ist zugleich der Sprung in die
* Arbeitsflaeche mit genau diesem Filter - eine Zahl, die man nicht verfolgen
* kann, beantwortet keine Frage.
*/
function Distribution({ eyebrow, heading, question, total, values, severity = false, onSelect }: DistributionProps) {
export function dashboardScanSourceLabel(label: string): string {
const parts = label.split(/\s+·\s+/);
const timestamp = parts.at(-1) ?? '';
if (parts.length < 2 || !/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(timestamp) || Number.isNaN(Date.parse(timestamp))) return label;
return parts.slice(0, -1).join(' · ');
}
export interface DashboardRiskTool {
id: string;
label: string;
sourceValues: string[];
count: number;
}
export function dashboardRiskTools(values: DynamicFacetValue[]): DashboardRiskTool[] {
const tools = new Map<string, DashboardRiskTool>();
values.forEach(value => {
const label = dashboardScanSourceLabel(value.label);
const id = label.toLocaleLowerCase('en-US');
const existing = tools.get(id);
if (existing) {
existing.sourceValues.push(value.value);
existing.count += value.count;
} else tools.set(id, { id, label, sourceValues: [value.value], count: value.count });
});
return [...tools.values()];
}
/** Compact distribution based on the supplied dashboard reference. */
function SegmentedDistribution({ heading, total, values, severity = false, formatLabel = label => label, onSelect }: SegmentedDistributionProps) {
return (
<section className="dashboard-block">
<div className="datahub-section-heading">
<div><span>{eyebrow}</span><h3>{heading}</h3></div>
<strong>{total.toLocaleString('en-US')}</strong>
</div>
<p className="dashboard-question">{question}</p>
<section className="dashboard-block dashboard-distribution">
<h2>{heading}</h2>
{values.length === 0 ? (
<p className="dashboard-empty">This scan report carries no such classification.</p>
) : (
<ul className="dashboard-bars">
{values.map(value => (
<li key={value.value}>
<button
className={`dashboard-bar${severity ? ` severity-${value.value}` : ''}`}
type="button"
onClick={() => onSelect(value.value)}
title={`Open in Work, filtered by ${value.label}`}
>
<span className="dashboard-bar-label">{value.label}</span>
<span className="dashboard-bar-track" aria-hidden="true">
<span className="dashboard-bar-fill" style={shareStyle(share(value.count, total))} />
</span>
<span className="dashboard-bar-count">{value.count.toLocaleString('en-US')}</span>
<span className="dashboard-bar-share">{share(value.count, total).toFixed(0)}%</span>
</button>
</li>
))}
</ul>
<>
<div className="dashboard-segmented-bar" aria-hidden="true">
{values.map((value, index) => (
<span
className={`dashboard-segment ${severity ? `severity-${value.value}` : `dashboard-viz-${index % 6 + 1}`}`}
key={value.value}
style={shareStyle(share(value.count, total))}
/>
))}
</div>
<ul className="dashboard-distribution-legend">
{values.map((value, index) => (
<li key={value.value}>
<button
className="dashboard-legend-button"
type="button"
onClick={() => onSelect(value.value)}
title={`Open in Work, filtered by ${value.label}`}
>
<span className={`dashboard-legend-swatch ${severity ? `severity-${value.value}` : `dashboard-viz-${index % 6 + 1}`}`} aria-hidden="true" />
<span className="dashboard-legend-copy">
<span>{formatLabel(value.label)}</span>
<strong>{value.count.toLocaleString('en-US')} <small>· {share(value.count, total).toFixed(0)}%</small></strong>
</span>
</button>
</li>
))}
</ul>
</>
)}
</section>
);
@ -97,7 +167,7 @@ function displayValue(observation: EnvironmentObservation): string {
return String(value);
}
function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; scanRunIds?: string[] }) {
function useEnvironmentSnapshot(projectId?: string, scanRunIds?: string[]) {
const [snapshot, setSnapshot] = useState<EnvironmentSnapshot>();
const [failed, setFailed] = useState(false);
@ -112,6 +182,11 @@ function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; sc
return () => { active = false; };
}, [projectId, scanRunIds?.join('|')]);
return { snapshot, failed };
}
function EnvironmentOverview({ projectId, snapshot, failed }: { projectId?: string; snapshot?: EnvironmentSnapshot; failed: boolean }) {
const observations = useMemo(() => {
const byProperty = new Map<string, EnvironmentObservation>();
snapshot?.observations.forEach(observation => byProperty.set(observation.property, observation));
@ -122,78 +197,203 @@ function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; sc
const operatingSystems = useMemo(() => [...new Set(snapshot?.observations
.filter(item => item.property === 'operating_system.name')
.map(displayValue) ?? [])], [snapshot]);
const highlightedSections = useMemo(() => snapshot?.sections.filter(section =>
/pki|certificate|trust|infrastructure|entra|laps|password|domain information|computer information/i.test(`${section.sectionKey} ${section.label}`)
) ?? [], [snapshot]);
if (!projectId) return null;
return (
<section className="dashboard-environment" aria-labelledby="environment-overview-heading">
<div className="datahub-section-heading">
<div><span>Environment</span><h3 id="environment-overview-heading">Directory overview</h3></div>
<div><h2 id="environment-overview-heading">Directory overview</h2></div>
{snapshot && <strong>{snapshot.sections.length.toLocaleString('en-US')} sections</strong>}
</div>
<p className="dashboard-question">What did the scanner observe about the environment beyond individual findings?</p>
{!snapshot && !failed && <p className="dashboard-empty">Loading scanner context…</p>}
{failed && <p className="dashboard-empty">Scanner context could not be loaded.</p>}
{snapshot && snapshot.sections.length === 0 && <p className="dashboard-empty">This import predates structured scanner-context capture.</p>}
{snapshot && (
<div className="dashboard-coverage-summary" aria-label="Directory coverage summary">
<span><strong>{snapshot.observations.length.toLocaleString('en-US')}</strong><small>typed observations</small></span>
<span><strong>{(snapshot.coverage.empty + snapshot.coverage.explicitlyAbsent).toLocaleString('en-US')}</strong><small>empty or absent</small></span>
<span><strong>{snapshot.coverage.restricted.toLocaleString('en-US')}</strong><small>restricted</small></span>
<span><strong>{snapshot.coverage.incomplete.toLocaleString('en-US')}</strong><small>incomplete</small></span>
</div>
)}
{snapshot && (observations.length > 0 || operatingSystems.length > 0) && (
<dl className="dashboard-facts">
{observations.map(({ label, observation }) => (
<div key={observation.property}><dt>{label}</dt><dd>{displayValue(observation)}</dd></div>
))}
{operatingSystems.length > 0 && <div><dt>Operating systems</dt><dd>{operatingSystems.join(', ')}</dd></div>}
{operatingSystems.length > 0 && <div><dt>OS</dt><dd>{operatingSystems.join(', ')}</dd></div>}
</dl>
)}
{snapshot && highlightedSections.length > 0 && (
<div className="dashboard-coverage" aria-label="Captured environment areas">
{highlightedSections.map(section => (
<span key={section.id} className={`dashboard-state dashboard-state-${section.state}`} title={section.label}>
{section.label} · {section.state.replace('-', ' ')}
</span>
))}
</section>
);
}
interface RiskProfileProps {
sourceFacet?: DynamicFacet;
categoryFacet?: DynamicFacet;
severityFacet?: DynamicFacet;
records: FindingRecord[];
profile: ImportProfile;
reviews: Record<string, Review>;
environmentSnapshot?: EnvironmentSnapshot;
onSelectTool(sourceValues: string[]): void;
onSelectCategory(value: string, sourceValues: string[]): void;
onSelectSeverity(value: string, sourceValues: string[]): void;
}
type RiskScoreObservation = Pick<EnvironmentObservation, 'property' | 'normalizedValue' | 'originalValue'>;
const pingCastleRiskCategoryCatalog = [
['risk.anomalies', 'Anomalies'],
['risk.stale_objects', 'Stale Objects'],
['risk.privileged_accounts', 'Privileged Accounts'],
['risk.trusts', 'Trusts']
] as const;
export function dashboardPingCastleRiskCategories(observations: RiskScoreObservation[], fallback: DynamicFacetValue[]): DynamicFacetValue[] {
return pingCastleRiskCategoryCatalog.map(([property, label]) => {
const observation = observations.find(item => item.property === property);
const sourceValue = observation?.normalizedValue ?? observation?.originalValue;
const numericValue = typeof sourceValue === 'number' ? sourceValue : Number(sourceValue);
const fallbackValue = fallback.find(value => value.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'));
return {
value: fallbackValue?.value ?? label.toLocaleLowerCase('en-US'),
label,
count: Number.isFinite(numericValue) ? numericValue : fallbackValue?.count ?? 0
};
});
}
const fullRiskProfileId = 'full-risk-profile';
function RiskProfile({ sourceFacet, categoryFacet, severityFacet, records, profile, reviews, environmentSnapshot, onSelectTool, onSelectCategory, onSelectSeverity }: RiskProfileProps) {
const tools = useMemo(() => dashboardRiskTools(sourceFacet?.values ?? []), [sourceFacet]);
const [selectedToolId, setSelectedToolId] = useState('');
const selectedTool = tools.find(tool => tool.id === selectedToolId)
?? tools.find(tool => tool.id.includes('pingcastle'))
?? tools[0];
const fullProfile = selectedToolId === fullRiskProfileId;
const effectiveProfileId = fullProfile ? fullRiskProfileId : selectedTool?.id ?? fullRiskProfileId;
const scopedRecords = useMemo(() => fullProfile
? records
: selectedTool && sourceFacet
? records.filter(record => matchesDynamicFacets(record, [sourceFacet], { [sourceFacet.id]: { values: selectedTool.sourceValues } }, reviews))
: [], [fullProfile, records, reviews, selectedTool, sourceFacet]);
const scopedFacets = useMemo(() => recountFacetsAgainstActiveFilters({
facets: [categoryFacet, severityFacet].filter((facet): facet is DynamicFacet => Boolean(facet)),
records: scopedRecords,
filters: {},
reviews,
statusFilter: 'all',
matchesStatus: (record, status) => profile.matchesStatus(record, status)
}), [categoryFacet, profile, reviews, scopedRecords, severityFacet]);
const scopedCategories = scopedFacets.find(facet => facet.id === categoryFacet?.id)?.values ?? [];
const categories = fullProfile
? tools.map(tool => ({ value: tool.id, label: tool.label, count: tool.count }))
: selectedTool?.id.includes('pingcastle')
? dashboardPingCastleRiskCategories(
environmentSnapshot?.observations.filter(observation => observation.tool.id === 'pingcastle') ?? [],
scopedCategories
)
: [...scopedCategories].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label));
const severities = scopedFacets.find(facet => facet.id === severityFacet?.id)?.values ?? [];
const deviations = scopedRecords.filter(record => profile.matchesStatus(record, 'deviation')).length;
const marked = scopedRecords.filter(record => reviews[record.id]?.include).length;
const severityTotal = severities.reduce((sum, value) => sum + value.count, 0);
let severityOffset = 0;
return (
<section className="dashboard-block dashboard-risk-profile">
<header className="dashboard-risk-header">
{tools.length > 0 ? <select className="dashboard-risk-tool" aria-label="Risk profile tool" value={effectiveProfileId} onChange={event => setSelectedToolId(event.target.value)}>
<option value={fullRiskProfileId}>Full Risk Profile</option>
{tools.map(tool => <option value={tool.id} key={tool.id}>{tool.label} Risk Profile</option>)}
</select> : <h2>Risk profile</h2>}
<div className="dashboard-marked-summary"><span>Marked for report</span><strong>{marked.toLocaleString('en-US')} / {deviations.toLocaleString('en-US')}</strong></div>
</header>
<div className="dashboard-risk-body">
{categories.length > 0 ? (
<ul className="dashboard-risk-categories">
{categories.slice(0, 4).map(category => (
<li key={category.value}>
<button type="button" onClick={() => {
if (fullProfile) {
const tool = tools.find(item => item.id === category.value);
if (tool) onSelectTool(tool.sourceValues);
} else if (selectedTool) onSelectCategory(category.value, selectedTool.sourceValues);
}} title={`Open in Work, filtered by ${category.label}`}>
<span>{category.label}</span><strong>{category.count.toLocaleString('en-US')}</strong>
</button>
</li>
))}
</ul>
) : <p className="dashboard-empty">No risk categories available.</p>}
<div className="dashboard-risk-severity">
{severities.length > 0 ? <>
<svg className="dashboard-risk-donut" viewBox="0 0 120 120" role="img" aria-label="Risk severity distribution">
<circle className="dashboard-risk-donut-track" cx="60" cy="60" r="48" pathLength="100" />
{severities.map(severity => {
const percentage = share(severity.count, severityTotal);
const offset = severityOffset;
severityOffset += percentage;
return <circle
className={`dashboard-risk-donut-segment severity-${severity.value}`}
cx="60"
cy="60"
r="48"
key={severity.value}
pathLength="100"
strokeDasharray={`${percentage} ${100 - percentage}`}
strokeDashoffset={-offset}
/>;
})}
</svg>
<ul className="dashboard-risk-legend">
{severities.map(severity => (
<li key={severity.value}>
<button type="button" onClick={() => fullProfile
? onSelectSeverity(severity.value, [])
: selectedTool && onSelectSeverity(severity.value, selectedTool.sourceValues)} title={`Open in Work, filtered by ${severity.label}`}>
<i className={`severity-${severity.value}`} aria-hidden="true" />
<span>{severity.label}</span>
<strong>{severity.count.toLocaleString('en-US')}</strong>
</button>
</li>
))}
</ul>
</> : <p className="dashboard-empty">No severity distribution available.</p>}
</div>
)}
{snapshot && (
<p className="dashboard-context-coverage">
{snapshot.observations.length.toLocaleString('en-US')} typed observations · {snapshot.coverage.empty + snapshot.coverage.explicitlyAbsent} empty or absent · {snapshot.coverage.restricted} restricted · {snapshot.coverage.incomplete} incomplete
</p>
)}
</div>
</section>
);
}
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const app = useApp();
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, resetFilters, setView } = app;
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
const facets = useMemo(
() => (activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : []),
[activeProfile, activeSheet, dataset, reviews]
);
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
if (!dataset || !activeSheet) return <EmptyWorkspace />;
const total = activeSheet.analysis.records.length;
const statusFacet: DynamicFacet | undefined = facets.find(facet => facet.source === 'profile-status');
const severityFacet: DynamicFacet | undefined = facets.find(facet => facet.severityScale);
/*
* Herkunft der Datensaetze. In der Projektaggregation traegt sie das Feld
* "Quelle: Scan" ("<Werkzeug> · <Zeitpunkt>"), bei einem einzelnen Import die
* Ersatzfacette ueber den Dateinamen. `dataset.sheets.length` waere hier
* falsch: darin stecken auch die verlinkten Ergebnistabellen, nicht nur die
* Scanberichte.
*/
const categoryFacet: DynamicFacet | undefined = facets.find(facet => facet.role === 'category' || /^(?:category|kategorie)$/i.test(facet.label.trim()));
const sourceFacet: DynamicFacet | undefined = facets.find(facet =>
/^(?:quelle|source):\s*scan$/i.test(facet.label.trim()) || facet.source === 'provenance');
const reports = sourceFacet?.values.length ?? 0;
// "Abweichung" ist der Wert, den das Profil selbst als Befund fuehrt; er
// heisst nicht in jedem Werkzeug gleich, traegt aber immer diesen Schluessel.
const deviations = statusFacet?.values.find(value => value.value === 'deviation')?.count ?? 0;
const marked = Object.values(reviews).filter(review => review.include).length;
const statusCount = (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0;
const deviations = statusCount('deviation');
const passed = statusCount('passed');
const notApplicable = statusCount('not-applicable');
const notRun = statusCount('not-run');
const errors = statusCount('error');
const explicitIoeRecordIds = dashboardExplicitIoeRecordIds(activeSheet.analysis.records);
const explicitIoes = explicitIoeRecordIds.length;
const complianceScore = passed + deviations > 0 ? Math.round((passed / (passed + deviations)) * 100) : 0;
/* Der Statusfilter ueberlebt Navigationsspruenge. Nur ein geklicktes
Statusdiagramm darf ihn in `apply` bewusst ersetzen. */
const jump = (apply: () => void) => {
resetFilters();
setStatusFilter(app.statusFilter);
@ -204,62 +404,74 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
return (
<div className="dashboard-page">
{/* Kontext statt Kennzahlkachel: die Zahlen darunter gelten fuer genau
diesen Bestand, und das muss ohne Rueckfrage ablesbar sein. */}
<p className="dashboard-context">
<strong>{dataset.fileName}</strong>
<span>{reports.toLocaleString('en-US')} scan {reports === 1 ? 'report' : 'reports'}</span>
<span>{total.toLocaleString('en-US')} records</span>
</p>
<EnvironmentOverview projectId={app.targetProjectId} scanRunIds={app.activeScanRunIds} />
<div className="dashboard-columns">
<Distribution
eyebrow="Coverage"
heading="Result status"
question="How much of this scan actually produced a usable result?"
total={total}
values={statusFacet?.values ?? []}
onSelect={value => jump(() => setStatusFilter(value))}
<section className="dashboard-status-rack" aria-label="Result status overview">
<DashboardStatusMetric
label="Compliance Score"
value={complianceScore}
percentage={complianceScore}
tone="all"
large
helper="Passed / (Passed + Deviation)"
/>
<DashboardStatusMetric label="Deviation" value={deviations} percentage={share(deviations, total)} tone="deviation" active={app.statusFilter === 'deviation'} onClick={() => jump(() => setStatusFilter('deviation'))} />
<DashboardStatusMetric label="Passed" value={passed} percentage={share(passed, total)} tone="passed" active={app.statusFilter === 'passed'} onClick={() => jump(() => setStatusFilter('passed'))} />
<DashboardStatusMetric label="Not Run" value={notRun} percentage={share(notRun, total)} tone="not-run" active={app.statusFilter === 'not-run'} onClick={() => jump(() => setStatusFilter('not-run'))} />
<section className="dashboard-status-summary" aria-label="Other result measures">
<div className="dashboard-status-summary-stack">
<button type="button" onClick={() => jump(() => setStatusFilter('not-applicable'))}><span>Not Applicable</span><strong>{notApplicable.toLocaleString('en-US')}</strong></button>
<button type="button" onClick={() => jump(() => setStatusFilter('error'))}><span>Error</span><strong>{errors.toLocaleString('en-US')}</strong></button>
</div>
<button className="dashboard-ioe-summary" type="button" disabled={explicitIoes === 0}
onClick={() => jump(() => { setStatusFilter('all'); setPathFindingIds(explicitIoeRecordIds); })}
title="Open IOEs in Work">
<span>IOEs found</span><strong>{explicitIoes.toLocaleString('en-US')}</strong>
</button>
</section>
</section>
<Distribution
eyebrow="Priority"
heading="Severity"
question="Which findings warrant the first look?"
<div className="dashboard-distribution-grid">
<SegmentedDistribution
heading="Severity distribution"
total={total}
values={severityFacet?.values ?? []}
severity
onSelect={value => severityFacet && jump(() => setFacetFilter(severityFacet.id, { values: [value] }))}
/>
<Distribution
eyebrow="Origin"
heading="Scan reports"
question="Which report contributed which share of the records?"
<SegmentedDistribution
heading="Scan source distribution"
total={total}
values={sourceFacet?.values ?? []}
formatLabel={dashboardScanSourceLabel}
onSelect={value => sourceFacet && jump(() => setFacetFilter(sourceFacet.id, { values: [value] }))}
/>
</div>
{/* Bearbeitungsstand: die einzige Zahl, die nicht aus dem Bericht,
sondern aus der eigenen Arbeit stammt. */}
<section className="dashboard-block">
<div className="datahub-section-heading">
<div><span>Progress</span><h3>Marked for the report</h3></div>
<strong>{marked.toLocaleString('en-US')}</strong>
</div>
<p className="dashboard-question">How much of the deviation backlog has been reviewed?</p>
<div className="dashboard-progress">
<span className="dashboard-bar-track" aria-hidden="true">
<span className="dashboard-bar-fill" style={shareStyle(share(marked, deviations))} />
</span>
<span className="dashboard-progress-legend">
{marked.toLocaleString('en-US')} of {deviations.toLocaleString('en-US')} deviations
</span>
</div>
</section>
<div className="dashboard-lower-grid">
<EnvironmentOverview projectId={app.targetProjectId} snapshot={environment.snapshot} failed={environment.failed} />
<RiskProfile
sourceFacet={sourceFacet}
categoryFacet={categoryFacet}
severityFacet={severityFacet}
records={activeSheet.analysis.records}
profile={activeProfile}
reviews={reviews}
environmentSnapshot={environment.snapshot}
onSelectTool={sourceValues => sourceFacet && jump(() => setFacetFilter(sourceFacet.id, { values: sourceValues }))}
onSelectCategory={(value, sourceValues) => categoryFacet && sourceFacet && jump(() => {
setFacetFilter(sourceFacet.id, { values: sourceValues });
setFacetFilter(categoryFacet.id, { values: [value] });
})}
onSelectSeverity={(value, sourceValues) => severityFacet && sourceFacet && jump(() => {
if (sourceValues.length) setFacetFilter(sourceFacet.id, { values: sourceValues });
setFacetFilter(severityFacet.id, { values: [value] });
})}
/>
</div>
</div>
);

View File

@ -159,48 +159,122 @@
min(1180px, 100%) begrenzt zu sein. */
.db-structure-page > *, .results-browser-page > * { width: 100% !important; max-width: none !important; margin-inline: 0 !important; }
/* Dashboard: Lagebild des geoeffneten Mandanten. Jeder Block beantwortet genau
eine Frage und ist zugleich der Einstieg in die Arbeitsflaeche mit dem
passenden Filter - eine Zahl ohne Weg dorthin waere Dekoration. */
.dashboard-page { box-sizing: border-box; width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
.dashboard-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--legacy-space-2) var(--legacy-space-4); margin: 0 0 var(--legacy-space-5); color: var(--muted); font-size: var(--ui-label-size); }
/* Dashboard: kompaktes Lagebild des geoeffneten Mandanten. Statuskarten und
Verteilungen bleiben direkte Einstiege in Work mit passendem Filter. */
.dashboard-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--space-4); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
.dashboard-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; padding: 0 var(--space-2); color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-context strong { color: var(--heading); font-size: var(--ui-body-size); }
.dashboard-environment { margin: 0 0 var(--legacy-space-5); padding: var(--legacy-space-4); border: var(--border-hair) solid var(--line); border-left: 3px solid var(--legacy-accent); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--legacy-space-3) var(--legacy-space-5); margin: 0; }
.dashboard-status-rack { display: grid; grid-template-columns: minmax(270px, 1.45fr) repeat(3, minmax(180px, .9fr)) minmax(250px, 1.2fr); gap: var(--space-2); }
.dashboard-status-metric { display: grid; grid-template-columns: 98px minmax(0, 1fr); align-items: center; gap: var(--space-4); min-width: 0; min-height: 190px; padding: var(--space-4); border: var(--border-hair) solid var(--line); border-radius: 0; color: var(--text); background: var(--panel); text-align: left; }
button.dashboard-status-metric { cursor: pointer; }
button.dashboard-status-metric:hover { border-color: currentColor; }
button.dashboard-status-metric:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
button.dashboard-status-metric[aria-pressed="true"] { outline: var(--bw-emphasis) solid currentColor; outline-offset: calc(-1 * var(--bw-emphasis)); }
.dashboard-status-metric-large { grid-template-columns: 154px minmax(0, 1fr); }
.dashboard-status-ring { position: relative; width: 98px; height: 98px; color: var(--donut-all); }
.dashboard-status-metric-large .dashboard-status-ring { width: 154px; height: 154px; }
.dashboard-status-ring svg { display: block; width: 100%; height: 100%; transform: rotate(-90deg); }
.dashboard-status-ring circle { fill: none; stroke-width: 12; }
.dashboard-status-metric-large .dashboard-status-ring circle { stroke-width: 10; }
.dashboard-status-ring-track { stroke: var(--donut-border); }
.dashboard-status-ring-progress { stroke: currentColor; }
.dashboard-status-ring > span { position: absolute; inset: 0; display: flex; align-items: baseline; justify-content: center; padding-top: 36px; color: var(--heading); font-variant-numeric: tabular-nums; }
.dashboard-status-metric-large .dashboard-status-ring > span { padding-top: 57px; }
.dashboard-status-ring > span strong { font: var(--fw-semibold) var(--donut-value-size) / 1 var(--font-donut-display); }
.dashboard-status-metric-large .dashboard-status-ring > span strong { font-size: calc(var(--donut-value-size) * 1.45); }
.dashboard-status-ring > span small { margin-left: var(--space-1); color: var(--heading); font-size: var(--ui-body-size); font-weight: var(--fw-semibold); }
.dashboard-status-ring-deviation { color: var(--donut-error); }
.dashboard-status-ring-passed { color: var(--donut-passed); }
.dashboard-status-ring-not-run { color: var(--donut-deviation); }
.dashboard-status-copy { display: grid; gap: var(--space-3); min-width: 0; }
.dashboard-status-copy > span { color: var(--heading); font-size: var(--ui-body-size); }
.dashboard-status-copy > strong { color: var(--heading); font-size: calc(var(--ui-heading-size) * 1.35); font-variant-numeric: tabular-nums; }
.dashboard-status-copy > strong small, .dashboard-status-copy > small { color: var(--muted); font-size: var(--ui-body-size); font-weight: var(--fw-regular); line-height: 1.35; }
.dashboard-status-summary { display: grid; grid-template-columns: minmax(0, 1fr) minmax(100px, .8fr); align-items: stretch; min-width: 0; min-height: 190px; padding: var(--space-4); border: var(--border-hair) solid var(--line); background: var(--panel); }
.dashboard-status-summary-stack { display: grid; align-content: center; padding-right: var(--space-4); border-right: var(--border-hair) solid var(--line-strong); }
.dashboard-status-summary-stack button { display: grid; gap: var(--space-2); min-height: 64px; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-status-summary-stack button + button { border-top: var(--border-hair) solid var(--line); }
.dashboard-status-summary-stack button:hover { background: var(--panel-hover); }
.dashboard-status-summary-stack button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-status-summary-stack span, .dashboard-ioe-summary span { color: var(--heading); font-size: var(--ui-body-size); }
.dashboard-status-summary-stack strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-ioe-summary { display: grid; align-content: center; gap: var(--space-4); min-width: 0; padding: 0 0 0 var(--space-5); border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.dashboard-ioe-summary:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-ioe-summary:disabled { cursor: default; }
.dashboard-ioe-summary strong { color: var(--accent); font: var(--fw-semibold) calc(var(--donut-value-size) * 1.45) / 1 var(--font-donut-display); font-variant-numeric: tabular-nums; }
.dashboard-distribution-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.dashboard-lower-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(420px, 1fr); align-items: stretch; gap: var(--space-3); }
.dashboard-environment { min-width: 0; margin: 0; padding: var(--space-4); border: var(--border-hair) solid var(--line); border-left: 3px solid var(--legacy-accent); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-environment .datahub-section-heading h2, .dashboard-block h2 { margin: 0; color: var(--heading); font-size: var(--ui-body-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; }
.dashboard-coverage-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: var(--space-4); padding-bottom: var(--space-4); border-bottom: var(--border-hair) solid var(--line); }
.dashboard-coverage-summary > span { display: grid; gap: var(--space-1); min-width: 0; padding: 0 var(--space-3); text-align: center; }
.dashboard-coverage-summary > span + span { border-left: var(--border-hair) solid var(--line-strong); }
.dashboard-coverage-summary strong { color: var(--heading); font-size: var(--ui-heading-size); font-variant-numeric: tabular-nums; }
.dashboard-coverage-summary small { color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3) var(--space-5); margin: 0; }
.dashboard-facts div { min-width: 0; }
.dashboard-facts dt { margin-bottom: var(--legacy-space-1); color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-facts dd { margin: 0; color: var(--heading); font-size: var(--ui-body-size); overflow-wrap: anywhere; }
.dashboard-coverage { display: flex; flex-wrap: wrap; gap: var(--legacy-space-2); margin-top: var(--legacy-space-4); }
.dashboard-state { padding: var(--legacy-space-1) var(--legacy-space-2); border: var(--border-hair) solid var(--line); color: var(--muted); background: var(--panel-raised); font-size: var(--ui-label-size); }
.dashboard-state-observed { border-color: var(--legacy-accent); color: var(--text); }
.dashboard-state-license-restricted, .dashboard-state-access-denied, .dashboard-state-parse-failed { border-color: var(--severity-high); }
.dashboard-context-coverage { margin: var(--legacy-space-4) 0 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); align-items: start; gap: var(--legacy-space-5); }
.dashboard-block { padding: var(--legacy-space-4); border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-question { margin: 0 0 var(--legacy-space-4); color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-block { min-width: 0; padding: var(--space-4); border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-distribution { display: grid; align-content: start; gap: var(--space-4); }
.dashboard-segmented-bar { display: flex; width: 100%; height: 28px; overflow: hidden; background: var(--surface-active); }
.dashboard-segment { flex: 0 0 var(--share); min-width: 2px; height: 100%; background: var(--severity, var(--viz-1)); }
.dashboard-segment + .dashboard-segment { border-left: var(--border-hair) solid var(--surface-panel); }
.dashboard-distribution-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.dashboard-legend-button { display: flex; align-items: flex-start; gap: var(--space-2); width: 100%; min-height: 52px; padding: var(--space-2); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-legend-button:hover { border-color: var(--line); background: var(--panel-hover); }
.dashboard-legend-button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-legend-swatch { flex: 0 0 10px; width: 10px; height: 10px; margin-top: 3px; background: var(--severity, var(--viz-1)); }
.dashboard-legend-copy { display: grid; gap: var(--space-1); min-width: 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-legend-copy > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dashboard-legend-copy strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-legend-copy small { color: var(--muted); font-size: var(--ui-control-size); font-weight: var(--fw-regular); }
.dashboard-viz-1 { --severity: var(--viz-1); }
.dashboard-viz-2 { --severity: var(--viz-2); }
.dashboard-viz-3 { --severity: var(--viz-3); }
.dashboard-viz-4 { --severity: var(--viz-4); }
.dashboard-viz-5 { --severity: var(--viz-5); }
.dashboard-viz-6 { --severity: var(--viz-6); }
.dashboard-empty { margin: 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-bars { display: grid; gap: var(--border-hair); margin: 0; padding: 0; list-style: none; }
/* Zeile als Raster: Bezeichnung, Anteilsbalken, Zahl, Anteil. Die beiden
rechten Spalten sind fest, damit Zahlen ueber alle Zeilen fluchten. */
.dashboard-bar { display: grid; grid-template-columns: 132px minmax(0, 1fr) 62px 44px; align-items: center; gap: var(--legacy-space-3); width: 100%; min-height: var(--row-height); padding: 0 var(--legacy-space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-bar:hover { background: var(--panel-hover); }
.dashboard-bar:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-bar-label { min-width: 0; overflow: hidden; color: var(--text); font-size: var(--ui-control-size); text-overflow: ellipsis; white-space: nowrap; }
/* --share setzt die Komponente zur Laufzeit je Zeile. Der Vorgabewert steht
hier und nicht in der Tokendatei: es ist ein Messwert dieser Darstellung,
keine Rolle der Designsprache. Ohne ihn bliebe ein Balken ohne Messwert
ungerendert statt leer. */
/* Die Spur nutzt --surface-active statt --surface-inset: letzteres ist im
hellen Skin dieselbe Flaeche wie das Panel, der ungefuellte Anteil des
Balkens waere dort unsichtbar gewesen. */
.dashboard-bar-track { --share: 0%; display: block; width: 100%; height: 10px; background: var(--surface-active); }
/* Ohne Severity-Klasse faellt der Balken auf den neutralen Ton zurueck - so
traegt der Ergebnisstatus keine Schwereaussage, die er nicht macht. */
.dashboard-bar-fill { display: block; width: var(--share); height: 100%; background: var(--severity, var(--legacy-accent)); }
.dashboard-bar-count { color: var(--heading); font-family: var(--font-mono); font-size: var(--ui-label-size); font-variant-numeric: tabular-nums; text-align: right; }
.dashboard-bar-share { color: var(--muted); font-family: var(--font-mono); font-size: var(--ui-label-size); font-variant-numeric: tabular-nums; text-align: right; }
.dashboard-progress { display: grid; gap: var(--legacy-space-2); }
.dashboard-progress-legend { color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-risk-profile { display: grid; align-content: start; }
.dashboard-risk-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); min-height: 32px; }
.dashboard-risk-tool { min-height: 30px; max-width: min(100%, 320px); padding: var(--space-1) var(--space-2); border: var(--border-hair) solid transparent; border-radius: 0; color: var(--heading); background-color: transparent; font-size: var(--ui-body-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; cursor: pointer; }
.dashboard-risk-tool:hover { border-color: var(--line-strong); background-color: var(--panel-hover); }
.dashboard-risk-tool:focus-visible { border-color: var(--focus-ring); outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-risk-tool option { color: var(--text); background-color: var(--surface-input); text-transform: none; }
.dashboard-marked-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-5); min-width: 240px; padding: var(--space-2) var(--space-3); border: var(--border-hair) solid var(--line); color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-marked-summary strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-risk-body { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(280px, 1.2fr); min-height: 190px; margin-top: var(--space-4); }
.dashboard-risk-categories, .dashboard-risk-legend { margin: 0; padding: 0; list-style: none; }
.dashboard-risk-categories { display: grid; align-content: center; padding-right: var(--space-5); border-right: var(--border-hair) solid var(--line-strong); }
.dashboard-risk-categories li + li { border-top: var(--border-hair) solid var(--line); }
.dashboard-risk-categories button, .dashboard-risk-legend button { display: grid; align-items: center; width: 100%; min-height: 42px; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-risk-categories button { grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); }
.dashboard-risk-categories button:hover, .dashboard-risk-legend button:hover { background: var(--panel-hover); }
.dashboard-risk-categories button:focus-visible, .dashboard-risk-legend button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-risk-categories span, .dashboard-risk-legend span { min-width: 0; overflow: hidden; font-size: var(--ui-body-size); text-overflow: ellipsis; white-space: nowrap; }
.dashboard-risk-categories strong, .dashboard-risk-legend strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-risk-severity { display: flex; align-items: center; justify-content: center; gap: var(--space-5); min-width: 0; padding-left: var(--space-5); }
.dashboard-risk-donut { flex: 0 0 150px; width: 150px; height: 150px; overflow: visible; transform: rotate(-90deg); }
.dashboard-risk-donut circle { fill: none; stroke-width: 13; }
.dashboard-risk-donut-track { stroke: var(--surface-active); }
.dashboard-risk-donut-segment { stroke: var(--severity, var(--info)); }
.dashboard-risk-legend { flex: 1 1 auto; min-width: 120px; }
.dashboard-risk-legend button { grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); }
.dashboard-risk-legend i { width: 12px; height: 12px; background: var(--severity, var(--info)); }
@media (max-width: 1200px) {
.dashboard-status-rack { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-status-summary { grid-column: 1 / -1; }
.dashboard-distribution-grid, .dashboard-lower-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.dashboard-status-rack { grid-template-columns: 1fr; }
.dashboard-status-summary { grid-column: auto; }
.dashboard-coverage-summary, .dashboard-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-risk-body { grid-template-columns: 1fr; }
.dashboard-risk-categories { padding-right: 0; padding-bottom: var(--space-4); border-right: 0; border-bottom: var(--border-hair) solid var(--line-strong); }
.dashboard-risk-severity { padding: var(--space-4) 0 0; }
}
/* DB-Struktur panel */
.db-structure-page { width: 100%; max-width: none; }

View File

@ -40,6 +40,15 @@ describe('DonutMetricCard binding design contract', () => {
expect(zero).not.toContain('<animate');
});
it('supports a single suffixed score without a duplicate percentage label', () => {
const html = renderToStaticMarkup(
<DonutMetricCard label="Compliance Score" value={36} valueSuffix="%" showPercentage={false} percentage={36} tone="all" />
);
expect(html).toContain('<strong>36%</strong>');
expect(html).not.toContain('<span>36%</span>');
});
it('becomes an accessible active filter control when clickable', () => {
const html = renderToStaticMarkup(
<DonutMetricCard

View File

@ -0,0 +1,98 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import { dashboardExplicitIoeCount, dashboardExplicitIoeRecordIds, dashboardPingCastleRiskCategories, dashboardRiskTools, dashboardScanSourceLabel } from '../../src/features/dashboard/DashboardPanel';
const component = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
describe('dashboard reference layout', () => {
it('uses the reference metric strip for compliance, primary statuses and the compact summary', () => {
expect(component).toContain('className="dashboard-status-rack"');
expect(component).toContain('label="Compliance Score"');
expect(component).toContain('<DashboardStatusMetric label="Deviation"');
expect(component).toContain('<DashboardStatusMetric label="Passed"');
expect(component).toContain('<DashboardStatusMetric label="Not Run"');
expect(component).toContain('className="dashboard-status-summary"');
expect(component).toContain('<span>Not Applicable</span>');
expect(component).toContain('<span>Error</span>');
expect(component).toContain('className="dashboard-ioe-summary" type="button"');
expect(component).toContain('setPathFindingIds(explicitIoeRecordIds)');
expect(styles).toMatch(/\.dashboard-status-rack\s*\{[^}]*repeat\(3,/s);
expect(styles).toContain('.dashboard-status-ring-track { stroke: var(--donut-border); }');
expect(styles).toContain('button.dashboard-status-metric:hover { border-color: currentColor; }');
});
it('uses paired segmented distributions and a compact lower dashboard row', () => {
expect(component).toContain('heading="Severity distribution"');
expect(component).toContain('heading="Scan source distribution"');
expect(component).toContain('className="dashboard-lower-grid"');
expect(styles).toMatch(/\.dashboard-distribution-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,/s);
expect(styles).toContain('.dashboard-segmented-bar');
});
it('matches the reference structure for directory coverage and risk profile', () => {
expect(component).toContain('className="dashboard-coverage-summary"');
expect(component).toContain('<small>typed observations</small>');
expect(component).toContain('className="dashboard-block dashboard-risk-profile"');
expect(component).toContain('className="dashboard-marked-summary"');
expect(component).toContain('className="dashboard-risk-tool"');
expect(component).toContain('<option value={fullRiskProfileId}>Full Risk Profile</option>');
expect(component).toContain('className="dashboard-risk-donut"');
expect(component).toContain('categories.slice(0, 4)');
expect(component).toContain('matchesDynamicFacets(record, [sourceFacet]');
expect(component).not.toContain('dashboard-review-block');
expect(styles).toMatch(/\.dashboard-facts\s*\{[^}]*grid-template-columns:\s*repeat\(4,/s);
expect(styles).toMatch(/\.dashboard-risk-body\s*\{[^}]*grid-template-columns:/s);
});
it('removes only a trailing scan timestamp from source labels', () => {
expect(dashboardScanSourceLabel('PurpleKnight · 2026-08-22T08:15:30.000Z')).toBe('PurpleKnight');
expect(dashboardScanSourceLabel('CIS-CAT Pro · 2026-08-22')).toBe('CIS-CAT Pro');
expect(dashboardScanSourceLabel('Scanner · Production')).toBe('Scanner · Production');
});
it('groups scan runs into one selectable risk profile per tool', () => {
expect(dashboardRiskTools([
{ value: 'pingcastle-a', label: 'PingCastle · 2026-08-22T08:15:30.000Z', count: 20 },
{ value: 'pingcastle-b', label: 'PingCastle · 2026-08-23T08:15:30.000Z', count: 24 },
{ value: 'purpleknight-a', label: 'PurpleKnight · 2026-08-23T09:00:00.000Z', count: 30 }
])).toEqual([
{ id: 'pingcastle', label: 'PingCastle', sourceValues: ['pingcastle-a', 'pingcastle-b'], count: 44 },
{ id: 'purpleknight', label: 'PurpleKnight', sourceValues: ['purpleknight-a'], count: 30 }
]);
});
it('keeps the complete PingCastle risk catalog including zero scores', () => {
expect(dashboardPingCastleRiskCategories([
{ property: 'risk.anomalies', normalizedValue: 20, originalValue: '20' },
{ property: 'risk.stale_objects', normalizedValue: 10, originalValue: '10' },
{ property: 'risk.privileged_accounts', normalizedValue: 7, originalValue: '7' },
{ property: 'risk.trusts', normalizedValue: 0, originalValue: '0' }
], [])).toEqual([
{ value: 'anomalies', label: 'Anomalies', count: 20 },
{ value: 'stale objects', label: 'Stale Objects', count: 10 },
{ value: 'privileged accounts', label: 'Privileged Accounts', count: 7 },
{ value: 'trusts', label: 'Trusts', count: 0 }
]);
});
it('counts only explicit IOE-found source results for the top summary', () => {
const record = (id: string, value: string): FindingRecord => ({
id, index: 0, title: id,
fields: [{ index: 0, label: 'Result', value }],
filled: [], meta: []
});
expect(dashboardExplicitIoeCount([
record('ioe', 'IOE Found'),
record('ioes', 'IOEs found'),
record('deviation', 'Failed'),
record('pass', 'Passed')
])).toBe(2);
expect(dashboardExplicitIoeRecordIds([
record('ioe', 'IOE Found'),
record('ioes', 'IOEs found'),
record('deviation', 'Failed')
])).toEqual(['ioe', 'ioes']);
});
});