diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 8b061f9..f55d4bb 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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. | diff --git a/src/components/DonutMetricCard/DonutMetricCard.tsx b/src/components/DonutMetricCard/DonutMetricCard.tsx index 0f8202a..7c0051a 100644 --- a/src/components/DonutMetricCard/DonutMetricCard.tsx +++ b/src/components/DonutMetricCard/DonutMetricCard.tsx @@ -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({
- {value.toLocaleString('en-US')} - {boundedPercentage}% + {value.toLocaleString('en-US')}{valueSuffix} + {showPercentage && {boundedPercentage}%}
; diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index 6c509d2..b7be91b 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -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 = <> +
+ + {roundedPercentage}% +
+
+ {label} + {large ? `${roundedPercentage} %` : <>{value.toLocaleString('en-US')} · {roundedPercentage}%} + {helper && {helper}} +
+ ; + const className = `dashboard-status-metric${large ? ' dashboard-status-metric-large' : ''}`; + return onClick + ? + :
{content}
; +} + +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(); + 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 ( -
-
-
{eyebrow}

{heading}

- {total.toLocaleString('en-US')} -
-

{question}

+
+

{heading}

{values.length === 0 ? (

This scan report carries no such classification.

) : ( -
    - {values.map(value => ( -
  • - -
  • - ))} -
+ <> + +
    + {values.map((value, index) => ( +
  • + +
  • + ))} +
+ )}
); @@ -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(); 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(); 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 (
-
Environment

Directory overview

+

Directory overview

{snapshot && {snapshot.sections.length.toLocaleString('en-US')} sections}
-

What did the scanner observe about the environment beyond individual findings?

{!snapshot && !failed &&

Loading scanner context…

} {failed &&

Scanner context could not be loaded.

} {snapshot && snapshot.sections.length === 0 &&

This import predates structured scanner-context capture.

} + {snapshot && ( +
+ {snapshot.observations.length.toLocaleString('en-US')}typed observations + {(snapshot.coverage.empty + snapshot.coverage.explicitlyAbsent).toLocaleString('en-US')}empty or absent + {snapshot.coverage.restricted.toLocaleString('en-US')}restricted + {snapshot.coverage.incomplete.toLocaleString('en-US')}incomplete +
+ )} {snapshot && (observations.length > 0 || operatingSystems.length > 0) && (
{observations.map(({ label, observation }) => (
{label}
{displayValue(observation)}
))} - {operatingSystems.length > 0 &&
Operating systems
{operatingSystems.join(', ')}
} + {operatingSystems.length > 0 &&
OS
{operatingSystems.join(', ')}
}
)} - {snapshot && highlightedSections.length > 0 && ( -
- {highlightedSections.map(section => ( - - {section.label} · {section.state.replace('-', ' ')} - - ))} +
+ ); +} + +interface RiskProfileProps { + sourceFacet?: DynamicFacet; + categoryFacet?: DynamicFacet; + severityFacet?: DynamicFacet; + records: FindingRecord[]; + profile: ImportProfile; + reviews: Record; + environmentSnapshot?: EnvironmentSnapshot; + onSelectTool(sourceValues: string[]): void; + onSelectCategory(value: string, sourceValues: string[]): void; + onSelectSeverity(value: string, sourceValues: string[]): void; +} + +type RiskScoreObservation = Pick; + +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 ( +
+
+ {tools.length > 0 ? :

Risk profile

} +
Marked for report{marked.toLocaleString('en-US')} / {deviations.toLocaleString('en-US')}
+
+
+ {categories.length > 0 ? ( +
    + {categories.slice(0, 4).map(category => ( +
  • + +
  • + ))} +
+ ) :

No risk categories available.

} +
+ {severities.length > 0 ? <> + + + {severities.map(severity => { + const percentage = share(severity.count, severityTotal); + const offset = severityOffset; + severityOffset += percentage; + return ; + })} + +
    + {severities.map(severity => ( +
  • +