Assessor_2.0/src/features/dashboard/DashboardPanel.tsx

1715 lines
102 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 { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
import { ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { useAssessmentContext } from '../context-layer/use-assessment-context';
import { publishContextReport } from '../../assessment-context/context-report-publication';
import { managementFindingDistribution, managementWorkspaceDeviationKeys } from '../../reporting/management-business-impact';
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { hasSelectedTimeslotScope } from '../../app/work-scope';
import { downloadText } from '../../exports/text-download';
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
import { OperatingSystemInventory } from './OperatingSystemInventory';
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
import { DashboardFactList, DashboardGlyph, type DashboardFact, type DashboardGlyphName } from './ClientTileParts';
export type { DashboardFact } from './ClientTileParts';
import { CloudInformationTile } from './CloudInformationTile';
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence';
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract';
import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './operating-system-inventory';
import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
import { DonutSmallArcMarker, donutArcPath, donutPercentageLabel } from '../../components/DonutMetricCard/donut-arc';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { TriangleIcon } from '../../components/TriangleIcon';
import type { ImportedDataset } from '../../domain/dataset';
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
import type { FindingRecord } from '../../domain/finding';
import type { ImportProfile } from '../../domain/import-profile';
import type { Review } from '../../domain/review';
import { weightedSecurityScore, type SecurityScoreContext, type SecuritySourceScore } from '../../domain/security-scoring';
import { dashboardSecuritySources } from './dashboard-security-score';
import { SECURITY_DOMAIN_FACET_ID, dashboardDomainAssignment, securityDomainCatalog } from './security-domains';
import { ScoringMethodology } from './ScoringMethodology';
import { RiskRegister } from './RiskRegister';
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, sourceScanNavigationSheet, type DynamicFacet, type DynamicFacetFilter, type DynamicFacetValue } from '../search-filter/dynamic-facets';
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from './dashboard-result-state';
export { dashboardResultState } from './dashboard-result-state';
import { AiOverviewPanel, useAiOverview } from '../../compliance/ui/AiOverviewPanel';
import { DEFENDER_FOR_IOT_PROFILE_ID } from '../../domain/defender-for-iot';
import { DefenderVulnerabilityPanel } from '../defender-for-iot/DefenderVulnerabilityPanel';
import { defenderModelForDataset } from '../defender-for-iot/defender-dataset-model';
const assessorApi = new AssessorApiClient();
export { dashboardDomainAssignment, dashboardDomainRecords } from './security-domains';
interface DashboardPanelProps {
onOpenWork(): void;
}
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;
}
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 percentageLabel = donutPercentageLabel(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" />
{percentage > 0 && <path className="dashboard-status-ring-progress" d={donutArcPath(percentage)} />}
<DonutSmallArcMarker percentage={percentage}/>
</svg>
<span><span className={`dashboard-donut-primary${value.toLocaleString('en-US').length >= 5 ? ' is-compact' : ''}`}><strong>{value.toLocaleString('en-US')}</strong><small>{percentageLabel}</small></span></span>
</div>
<div className="dashboard-status-copy">
<span>{label}</span>
{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')}, ${percentageLabel}`} aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
: <article className={className}>{content}</article>;
}
interface SegmentedDistributionProps {
heading: string;
total: number;
values: DynamicFacetValue[];
severity?: boolean;
formatLabel?(label: string): string;
onSelect(value: string): void;
}
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;
}
const dashboardCisCatSeverityValue = 'dashboard-cis-cat-without-severity';
export interface DashboardCisCatSeverityRemainder extends DynamicFacetValue {
sourceValues: string[];
}
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()];
}
export function dashboardCisCatSeverityRemainder(
sourceValues: DynamicFacetValue[],
cisCatSeverityValues: DynamicFacetValue[]
): DashboardCisCatSeverityRemainder | undefined {
const tool = dashboardRiskTools(sourceValues)
.find(item => /^cis-cat(?:\s+pro)?$/i.test(item.label.trim()));
if (!tool) return undefined;
const classified = cisCatSeverityValues.reduce((sum, value) => sum + value.count, 0);
const count = Math.max(0, tool.count - classified);
return count > 0 ? {
value: dashboardCisCatSeverityValue,
label: 'CIS-CAT',
count,
sourceValues: tool.sourceValues
} : undefined;
}
/** 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 dashboard-distribution">
<h2>{heading}</h2>
{values.length === 0 ? (
<p className="dashboard-empty">This scan report carries no such classification.</p>
) : (
<>
<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>
);
}
const overviewProperties: Array<[string, string]> = [
['domain.fqdn', 'Domain'],
['domain.functional_level', 'Domain functional level'],
['forest.functional_level', 'Forest functional level'],
['forest.schema_version', 'Schema version'],
['domain.controller_count', 'Domain controllers'],
['domain.recycle_bin_enabled', 'AD Recycle Bin'],
['backup.last_detected_at', 'Last AD backup'],
['laps.legacy.installation_date', 'Legacy LAPS'],
['laps.windows.installation_date', 'Windows LAPS'],
['logging.wef.configuration_count', 'WEF configurations'],
['kerberos.krbtgt.password_last_changed', 'krbtgt password changed'],
['report.engine_version', 'PingCastle engine']
];
function displayValue(observation: EnvironmentObservation): string {
if (observation.state === 'explicitly-absent') return observation.originalValue === false ? 'Not detected' : 'Not present';
const value = observation.normalizedValue ?? observation.originalValue;
if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled';
if (value === null || value === '') return 'No value';
return String(value);
}
function useEnvironmentSnapshot(projectId?: string, scanRunIds?: string[]) {
const [snapshot, setSnapshot] = useState<EnvironmentSnapshot>();
const [failed, setFailed] = useState(false);
useEffect(() => {
let active = true;
setSnapshot(undefined);
setFailed(false);
if (!projectId) return () => { active = false; };
assessorApi.getEnvironmentSnapshot({ projectId, scanRunIds })
.then(value => { if (active) setSnapshot(value); })
.catch(() => { if (active) setFailed(true); });
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));
return overviewProperties
.map(([property, label]) => ({ label, observation: byProperty.get(property) }))
.filter((item): item is { label: string; observation: EnvironmentObservation } => Boolean(item.observation));
}, [snapshot]);
const operatingSystems = useMemo(() => [...new Set(snapshot?.observations
.filter(item => item.property === 'operating_system.name')
.map(displayValue) ?? [])], [snapshot]);
if (!projectId) return null;
return (
<section className="dashboard-environment" aria-labelledby="environment-overview-heading">
<div className="datahub-section-heading">
<div><h2 id="environment-overview-heading">Directory overview</h2></div>
{snapshot && <strong>{snapshot.sections.length.toLocaleString('en-US')} sections</strong>}
</div>
{!snapshot && !failed && <p className="dashboard-empty">Loading Client Information…</p>}
{failed && <p className="dashboard-empty">Client Information 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>OS</dt><dd>{operatingSystems.join(', ')}</dd></div>}
</dl>
)}
</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 ? <>
<div className="dashboard-risk-donut" role="img" aria-label="Risk severity distribution: 100%">
<svg viewBox="0 0 120 120" aria-hidden="true">
<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 <g key={severity.value}><path
className={`dashboard-risk-donut-segment severity-${severity.value}`}
d={donutArcPath(percentage, offset)}
/><DonutSmallArcMarker percentage={percentage} offset={offset} className={`dashboard-risk-donut-marker severity-${severity.value}`}/></g>;
})}
</svg>
<span><span className="dashboard-donut-primary"><strong>{severityTotal.toLocaleString('en-US')}</strong><small>100%</small></span></span>
</div>
<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>
</div>
</section>
);
}
interface DashboardEnvironmentMetric {
label: string;
value: number;
detail?: string;
restricted?: boolean;
tone?: string;
onClick?(): void;
}
interface DashboardEnvironmentSummary {
directoryFacts: Array<{ label: string; value: string }>;
coverage: DashboardEnvironmentMetric[];
identity: DashboardEnvironmentMetric[];
controlPaths: DashboardEnvironmentMetric[];
certificates: DashboardEnvironmentMetric[];
gpo: DashboardEnvironmentMetric[];
risk: DashboardEnvironmentMetric[];
resilienceFacts: Array<{ label: string; value: string }>;
}
function observationFor(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string) {
return snapshot?.observations.find(observation => observation.property === property
|| Boolean(label && observation.label === label && (!sectionKey || observation.sectionKey === sectionKey)));
}
function observationNumber(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string): number {
const observation = observationFor(snapshot, property, label, sectionKey);
const value = observation?.normalizedValue ?? observation?.originalValue;
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : 0;
}
function observationText(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string): string {
const observation = observationFor(snapshot, property, label);
return observation ? displayValue(observation) : 'No data';
}
function sectionObservationCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
return snapshot?.observations.filter(observation => observation.sectionKey === sectionKey).length ?? 0;
}
function sectionFor(snapshot: EnvironmentSnapshot | undefined, sectionKey: string) {
return snapshot?.sections.find(section => section.sectionKey === sectionKey);
}
function sectionTableCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
return sectionFor(snapshot, sectionKey)?.tables.length ?? 0;
}
function sectionRowCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
return sectionFor(snapshot, sectionKey)?.tables.reduce((sum, table) => sum + table.rows.length, 0) ?? 0;
}
function isRestricted(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): boolean {
return ['license-restricted', 'access-denied'].includes(sectionFor(snapshot, sectionKey)?.state ?? '');
}
export function dashboardEnvironmentSummary(snapshot?: EnvironmentSnapshot): DashboardEnvironmentSummary {
const operatingSystems = [...new Set(snapshot?.observations
.filter(observation => observation.property === 'operating_system.name')
.map(displayValue) ?? [])];
return {
directoryFacts: [
{ label: 'Domain', value: observationText(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'Domain FL', value: observationText(snapshot, 'domain.functional_level', 'Domain Functional Level') },
{ label: 'Forest FL', value: observationText(snapshot, 'forest.functional_level', 'Forest Functional Level') },
{ label: 'Schema', value: observationText(snapshot, 'forest.schema_version', 'Schema version') },
{ label: 'Domain Controllers', value: observationText(snapshot, 'domain.controller_count', 'DC count') }
],
coverage: [
{ label: 'Observed', value: snapshot?.coverage.observed ?? 0 },
{ label: 'Empty', value: snapshot?.coverage.empty ?? 0 },
{ label: 'Explicitly absent', value: snapshot?.coverage.explicitlyAbsent ?? 0 },
{ label: 'Restricted', value: snapshot?.coverage.restricted ?? 0 },
{ label: 'Incomplete', value: snapshot?.coverage.incomplete ?? 0 }
],
identity: [
{ label: 'Admin groups', value: sectionObservationCount(snapshot, 'sectionAdminGroups'), detail: 'observations', restricted: isRestricted(snapshot, 'sectionAdminGroups') },
{ label: 'Never-expiring users', value: sectionObservationCount(snapshot, 'sectionneverexpiresuser') },
{ label: 'Account analysis', value: sectionObservationCount(snapshot, 'sectionComputerInformation'), restricted: isRestricted(snapshot, 'sectionComputerInformation') },
{ label: 'Honey Pot', value: sectionObservationCount(snapshot, 'sectionUserInformation'), restricted: isRestricted(snapshot, 'sectionUserInformation') },
{ label: 'Trusted delegation', value: sectionObservationCount(snapshot, 'sectionenabledtrusteddelegationcomputer') }
],
controlPaths: [
{ label: 'Control paths', value: sectionObservationCount(snapshot, 'sectionControlPathsAnalysis'), detail: 'observations' },
{ label: 'Indirect-path tables', value: sectionTableCount(snapshot, 'sectionControlPathsAnalysis') },
{ label: 'Foreign-domain rows', value: sectionRowCount(snapshot, 'sectionControlPathsAnalysis') },
{ label: 'Trusts score', value: observationNumber(snapshot, 'risk.trusts', 'Trusts score') },
{ label: 'Discovered domains', value: sectionRowCount(snapshot, 'sectionTrustsdetails') }
],
certificates: [
{ label: 'PKI', value: sectionObservationCount(snapshot, 'sectionPKI'), detail: 'observations' },
{ label: 'Certificate templates', value: observationNumber(snapshot, 'pki.certificate_template_count', 'Certificate Templates list row count', 'sectionPKI') },
{ label: 'Certificates', value: observationNumber(snapshot, 'pki.certificate_count', 'Certificates list row count', 'sectionPKI') },
{ label: 'DC certificates', value: observationNumber(snapshot, 'pki.dc_certificate_count', 'DC Certificates list row count', 'sectionPKI') },
{ label: 'Delegations', value: observationNumber(snapshot, 'pki.delegation_count', 'Delegations list row count', 'sectionPKI') }
],
gpo: [
{ label: 'GPO', value: sectionObservationCount(snapshot, 'sectionGPO'), detail: 'observations' },
{ label: 'Privilege assignments', value: observationNumber(snapshot, 'gpo.privilege_count', 'Privileges list row count', 'sectionGPO') },
{ label: 'Password policies', value: observationNumber(snapshot, 'password.policy_count', 'Password policies list row count', 'sectionPasswordPolicies') },
{ label: 'Audit settings', value: observationNumber(snapshot, 'gpo.audit_setting_count', 'Audit settings list row count', 'sectionGPO') },
{ label: 'Defender ASR', value: observationNumber(snapshot, 'gpo.defender_asr_count', 'Defender ASR row count', 'sectionGPO') },
{ label: 'Firewall rules', value: observationNumber(snapshot, 'gpo.firewall_rule_count', 'Firewall Rules row count', 'sectionGPO') }
],
risk: [
{ label: 'Domain risk', value: observationNumber(snapshot, 'risk.global', 'Domain Risk Level') },
{ label: 'Anomalies', value: observationNumber(snapshot, 'risk.anomalies', 'Anomalies score') },
{ label: 'Privileged accounts', value: observationNumber(snapshot, 'risk.privileged_accounts', 'Privileged Accounts score') },
{ label: 'Stale objects', value: observationNumber(snapshot, 'risk.stale_objects', 'Stale Objects score') },
{ label: 'Trusts', value: observationNumber(snapshot, 'risk.trusts', 'Trusts score') }
],
resilienceFacts: [
{ label: 'AD Recycle Bin', value: observationText(snapshot, 'domain.recycle_bin_enabled', 'Recycle Bin enabled') },
{ label: 'Last backup', value: observationText(snapshot, 'backup.last_detected_at', 'Last detected AD backup') },
{ label: 'krbtgt changed', value: observationText(snapshot, 'kerberos.krbtgt.password_last_changed', 'krbtgt password last changed') },
{ label: 'Legacy LAPS', value: observationText(snapshot, 'laps.legacy.installation_date', 'Legacy LAPS installation date') },
{ label: 'Windows LAPS', value: observationText(snapshot, 'laps.windows.installation_date', 'Windows LAPS installation date') },
{ label: 'WEF', value: observationText(snapshot, 'logging.wef.configuration_count', 'WEF configuration count') },
{ label: 'OS', value: operatingSystems.length ? operatingSystems.join(', ') : 'No data' }
]
};
}
/**
* Umgebungsdetails der geladenen Client Info.
*
* Die Zone beantwortet, was ausserhalb der Befunde ueber die Umgebung bekannt
* ist: Verzeichnis, Bestand, Passwortrichtlinie, PKI, Wiederanlauf und die
* werkzeugeigenen Kennzahlen. Unbekanntes bleibt ein Gedankenstrich - eine
* fehlende Angabe wird nie als Null dargestellt.
*/
export interface DashboardEnvironmentDetail {
operatingSystems: OperatingSystemInventoryGroup[];
directory: DashboardFact[];
inventory: DashboardFact[];
passwordPolicy: DashboardFact[];
certificates: DashboardFact[];
resilience: DashboardFact[];
toolContext: DashboardFact[];
restricted: string[];
}
const UNKNOWN_FACT = '—';
/**
* PingCastle schreibt den Hilfetext einer Zelle als weitere Attribute in
* denselben Text ("Default Domain Policy " data-bs-html=..."). Fuer die
* Anzeige bleibt der Wert vor dem Attributrest stehen; der vollstaendige
* Originalwert bleibt in der Beobachtung erhalten.
*/
function factText(value: string | undefined): string {
const cleaned = (value ?? '').replace(/\s*"\s*data-bs[\s\S]*$/i, '').trim();
return cleaned || UNKNOWN_FACT;
}
/**
* A native forest name (PingCastle XML, Purple Knight) takes priority over one
* the PingCastle HTML import derived from the configuration partition path; a
* derived value is shown with its derivation note.
*/
function forestFact(snapshot: EnvironmentSnapshot | undefined): DashboardFact {
const observed = (snapshot?.observations ?? []).filter(item => item.property === 'forest.name' && item.state === 'observed');
const observation = observed.find(item => !item.attributes.derivation) ?? observed[0];
if (!observation) return { label: 'Forest', value: UNKNOWN_FACT };
const value = factText(displayValue(observation));
return observation.attributes.derivation === 'configuration-partition-dn' && value !== UNKNOWN_FACT
? { label: 'Forest', value, note: 'derived from configuration path' }
: { label: 'Forest', value };
}
function observationFact(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string): string {
const observation = observationFor(snapshot, property, label);
return observation ? factText(displayValue(observation)) : UNKNOWN_FACT;
}
function optionalObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string): number | undefined {
const observation = observationFor(snapshot, property, label, sectionKey);
if (!observation) return undefined;
const value = observation.normalizedValue ?? observation.originalValue;
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : undefined;
}
function numberFact(value: number | undefined): string {
return value === undefined ? UNKNOWN_FACT : value.toLocaleString('en-US');
}
function environmentTable(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp) {
return sectionFor(snapshot, sectionKey)?.tables.find(table => tableName.test(table.name.trim()));
}
/** Erster Zeilenwert einer benannten Tabelle unter ihrer Spaltenueberschrift. */
function environmentTableValue(
snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp, header: RegExp
): string {
const table = environmentTable(snapshot, sectionKey, tableName);
const column = table?.headers.findIndex(value => header.test(value.trim())) ?? -1;
if (!table || column < 0) return UNKNOWN_FACT;
return factText(table.rows[0]?.[column] === null || table.rows[0]?.[column] === undefined ? '' : String(table.rows[0][column]));
}
function environmentTableRowCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp): number | undefined {
return environmentTable(snapshot, sectionKey, tableName)?.rows.length;
}
function restrictedSections(snapshot: EnvironmentSnapshot | undefined): string[] {
return (snapshot?.sections ?? [])
.filter(section => ['license-restricted', 'access-denied'].includes(section.state))
.map(section => section.label);
}
export function dashboardEntraTenantCount(snapshot?: EnvironmentSnapshot): number | undefined {
const observed = (snapshot?.observations ?? []).filter(observation => observation.state === 'observed');
const ids = new Set(observed.filter(observation => observation.property === 'entra.tenant_id')
.map(observation => displayValue(observation).trim()).filter(Boolean));
if (ids.size) return ids.size;
const names = new Set(observed.filter(observation => observation.property === 'entra.tenant_name')
.map(observation => observation.subject.identity?.trim() || displayValue(observation).trim()).filter(Boolean));
return names.size || undefined;
}
export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): DashboardEnvironmentDetail {
const userTable = (header: RegExp) => dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', header);
const categoryScores = (snapshot?.observations ?? [])
.filter(observation => /^purple_knight\.(?:ad|entra)\.category_score\./.test(observation.property))
.map(observation => ({
label: `Purple Knight ${observation.property.startsWith('purple_knight.entra.') ? 'Entra' : 'AD'} · ${observation.label}`,
value: numberFact(optionalObservationNumber(snapshot, observation.property))
}));
const entraDirectoryValue = observationFor(snapshot, 'entra.tenant_name')
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
return {
operatingSystems: operatingSystemInventory(snapshot),
directory: [
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
forestFact(snapshot),
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
{ label: 'Entra ID', value: entraDirectoryValue }
],
inventory: [
{ label: 'User accounts', value: numberFact(userTable(/^nb user accounts$/i)) },
{ label: 'Enabled / disabled', value: `${numberFact(userTable(/^nb enabled$/i))} / ${numberFact(userTable(/^nb disabled$/i))}` },
{ label: 'Password never expires', value: numberFact(userTable(/^nb pwd never expire$/i)) },
{ label: 'Computer objects', value: numberFact(dashboardEnvironmentTableNumber(snapshot, 'sectionComputerInformation', /^nb computer accounts$/i)) },
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
{ label: 'Admin groups', value: numberFact(environmentTableRowCount(snapshot, 'sectionAdminGroups', /^admin groups list$/i)) },
{ label: 'Trusts', value: numberFact(environmentTableRowCount(snapshot, 'sectionTrustsdetails', /^trusts list$/i)) },
{ label: 'Assessed host', value: observationFact(snapshot, 'host.name') }
],
passwordPolicy: [
{ label: 'Policy', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^policy name$/i) },
{ label: 'Complexity', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^complexity$/i) },
{ label: 'Minimum length', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^min password length$/i) },
{ label: 'History', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^password history$/i) },
{ label: 'Maximum age', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^max password age$/i) },
{ label: 'Lockout threshold', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^lockout threshold$/i) }
],
certificates: [
{ label: 'Certificate templates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^certificate templates list$/i)) },
{ label: 'Certificates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^certificates list$/i)) },
{ label: 'DC certificates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^dc certificates list$/i)) },
{ label: 'PKI delegations', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^delegations list$/i)) }
],
resilience: [
{ label: 'AD Recycle Bin', value: observationFact(snapshot, 'domain.recycle_bin_enabled', 'Recycle Bin enabled') },
{ label: 'Last AD backup', value: observationFact(snapshot, 'backup.last_detected_at', 'Last detected AD backup') },
{ label: 'krbtgt password', value: observationFact(snapshot, 'kerberos.krbtgt.password_last_changed', 'krbtgt password last changed') },
{ label: 'krbtgt version', value: observationFact(snapshot, 'kerberos.krbtgt.version', 'krbtgt version') },
{ label: 'Legacy LAPS', value: observationFact(snapshot, 'laps.legacy.installation_date', 'Legacy LAPS installation date') },
{ label: 'Windows LAPS', value: observationFact(snapshot, 'laps.windows.installation_date', 'Windows LAPS installation date') },
{ label: 'WEF configurations', value: observationFact(snapshot, 'logging.wef.configuration_count', 'WEF configuration count') }
],
toolContext: [
{ label: 'PingCastle engine', value: observationFact(snapshot, 'report.engine_version', 'PingCastle engine version') },
{ label: 'PingCastle domain risk', value: numberFact(optionalObservationNumber(snapshot, 'risk.domain') ?? optionalObservationNumber(snapshot, 'risk.global', 'Domain Risk Level')) },
{ label: 'Purple Knight AD posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.security_posture_score')) },
{ label: 'Purple Knight AD IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.ioes_found')) },
{ label: 'Purple Knight AD evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.evaluated')) },
{ label: 'Purple Knight Entra tenant', value: observationFact(snapshot, 'entra.tenant_name', 'Entra tenant') },
{ label: 'Purple Knight Entra posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.security_posture_score')) },
{ label: 'Purple Knight Entra IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.ioes_found')) },
{ label: 'Purple Knight Entra evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.evaluated')) },
...categoryScores,
{ label: 'CIS-CAT benchmark', value: observationFact(snapshot, 'benchmark.name') },
{ label: 'CIS-CAT profile', value: observationFact(snapshot, 'benchmark.profile') },
{ label: 'CIS-CAT score', value: numberFact(optionalObservationNumber(snapshot, 'benchmark.score')) },
...(snapshot?.observations ?? [])
.filter(observation => observation.tool.id === 'maester' && ['entra.tenant_id', 'entra.tenant_name', 'report.generated_at', 'report.engine_version', 'maester.duration', 'maester.tests.actual_total', 'maester.runner.host', 'maester.runner.os', 'maester.powershell.version'].includes(observation.property))
.map(observation => ({ label: observation.label.startsWith('Maester') ? observation.label : `Maester ${observation.label}`, value: observation.state === 'observed' ? factText(displayValue(observation)) : UNKNOWN_FACT }))
],
restricted: restrictedSections(snapshot)
};
}
/**
* Kompakter, quellengestuetzter Verzeichniskontext fuer die erste Leselinie.
* Die Werte verwenden dieselben normalisierten Beobachtungen wie Environment
* Detail; fehlende Scannerangaben bleiben ausdruecklich unbekannt.
*/
export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): DashboardFact[] {
const entra = observationFor(snapshot, 'entra.tenant_name')
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
const dcOperatingSystems = [...new Set((snapshot?.observations ?? [])
.filter(observation => observation.state === 'observed'
&& observation.property === 'operating_system.name'
&& observation.subject.type === 'computer-information.domain-controllers')
.map(observation => factText(displayValue(observation)))
.filter(value => value !== UNKNOWN_FACT))];
return [
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
forestFact(snapshot),
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
{ label: 'DC OS', value: dcOperatingSystems.length ? dcOperatingSystems.join(', ') : UNKNOWN_FACT },
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
{ label: 'Entra ID', value: entra }
];
}
interface DashboardMetricTileProps {
label: string;
value: string;
tone?: string;
emphasis?: boolean;
active?: boolean;
onClick?(): void;
}
function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false, active, onClick }: DashboardMetricTileProps) {
const content = <><span>{label}</span><strong>{value}</strong></>;
const className = `client-info-metric tone-${tone}${emphasis ? ' is-emphasis' : ''}`;
return onClick
? <button className={className} type="button" aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
: <article className={className}>{content}</article>;
}
/** Source-backed summary facts appear in the tiles and remain available in Results. */
function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evidenceGroup }: {
title: string; items: DashboardFact[]; glyph: DashboardGlyphName;
overviewItems?: DashboardFact[];
snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup;
}) {
const tileItems = (overviewItems ?? items).slice(0, 4);
return <section className="client-env-group">
<header className="client-env-group-header">
<h3 className="client-env-group-title">
<DashboardGlyph name={glyph} />
<span>{title}</span>
</h3>
<EnvironmentResultsButton title={title}>
<DashboardFactList items={items}/>
<EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/>
</EnvironmentResultsButton>
</header>
<DashboardFactList items={tileItems}/>
</section>;
}
interface DashboardDonutSegment {
label: string;
value: number;
tone: string;
onClick?(): void;
}
function DashboardDonut({ total, centerLabel, segments }: { total: number; centerLabel: string; segments: DashboardDonutSegment[] }) {
let offset = 0;
return <div className="client-info-donut-layout">
<div className="client-info-donut" role="img" aria-label={`${centerLabel}: ${total.toLocaleString('en-US')}`}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="client-info-donut-track" cx="60" cy="60" r="48" pathLength="100" />
{segments.filter(segment => segment.value > 0).map(segment => {
const percentage = share(segment.value, total);
const segmentOffset = offset;
offset += percentage;
return <g key={segment.label}><path className={`client-info-donut-segment tone-${segment.tone}`}
d={donutArcPath(percentage, segmentOffset)} />
<DonutSmallArcMarker percentage={percentage} offset={segmentOffset} className={`client-info-donut-marker tone-${segment.tone}`}/></g>;
})}
</svg>
<span><strong>{total.toLocaleString('en-US')}</strong><small>100%</small><em>{centerLabel}</em></span>
</div>
<ul className="client-info-legend">
{segments.map(segment => <li key={segment.label}>{segment.onClick
? <button type="button" onClick={segment.onClick}><i className={`tone-${segment.tone}`} /><span>{segment.label}</span><strong>{segment.value.toLocaleString('en-US')}</strong></button>
: <span className="client-info-legend-row"><i className={`tone-${segment.tone}`} /><span>{segment.label}</span><strong>{segment.value.toLocaleString('en-US')}</strong></span>}</li>)}
</ul>
</div>;
}
export function DashboardMetricBars({ items, tone = 'accent' }: { items: DashboardEnvironmentMetric[]; tone?: string }) {
const maximum = Math.max(1, ...items.map(item => item.value));
const ordinalTones = ['critical', 'high', 'medium', 'accent', 'neutral'];
return <ul className="client-info-bars">{items.map((item, index) => {
const resolvedTone = item.tone ?? (tone === 'accent' ? ordinalTones[index % ordinalTones.length] : tone);
const content = <>
<span className="client-info-bar-label">{item.label}</span>
<strong><span>{item.value.toLocaleString('en-US')}</span>{item.detail && <small className="client-info-bar-detail">{item.detail}</small>}</strong>
<small className={`client-info-bar-status${item.restricted ? '' : ' is-empty'}`} title={item.restricted ? 'Some source details are restricted' : undefined} aria-hidden={!item.restricted}>{item.restricted ? 'Restricted' : '\u00a0'}</small>
<span className="client-info-bar-track"><i className={`tone-${resolvedTone}`} style={shareStyle(share(item.value, maximum))} /></span>
</>;
return <li key={item.label}>{item.onClick
? <button className="client-info-bar-row" type="button" onClick={item.onClick}>{content}</button>
: <div className="client-info-bar-row">{content}</div>}
</li>;
})}</ul>;
}
interface DashboardCardSummary {
label: string;
value: number | string;
detail?: string;
}
export function DashboardLargeCard({ title, meta, summary, className = '', details, initiallyExpanded = true, children }: { title: string; meta?: string; summary?: DashboardCardSummary; className?: string; details?: ReactNode; initiallyExpanded?: boolean; children?: ReactNode }) {
const [expanded, setExpanded] = useState(initiallyExpanded);
const detailsId = useId();
return <section className={`client-info-card client-info-detail-card ${expanded ? 'is-expanded' : 'is-collapsed'} ${className}`.trim()}>
<header onClick={() => setExpanded(value => !value)}>
<span className="client-info-card-heading"><h2>{title}</h2>{summary && <span className="client-info-card-primary">— {summary.label} {typeof summary.value === 'number' ? summary.value.toLocaleString('en-US') : summary.value}{summary.detail ? ` ${summary.detail}` : ''}</span>}{meta && <span className="client-info-card-meta">{meta}</span>}</span>
<button className="client-info-card-toggle" type="button" aria-expanded={expanded} aria-controls={detailsId}
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}>
<TriangleIcon direction={expanded ? 'down' : 'right'} />
</button>
</header>
{expanded && <div className="client-info-card-content" id={detailsId}>
<div className="client-info-card-details">{children}</div>
{details && <div className="client-info-card-extra">{details}</div>}
</div>}
</section>;
}
export function DashboardDonutCard({ title, meta, initiallyExpanded = true, children }: { title: string; meta: string; initiallyExpanded?: boolean; children: ReactNode }) {
const [expanded, setExpanded] = useState(initiallyExpanded);
const detailsId = useId();
return <section className={`client-info-card client-info-donut-card ${expanded ? 'is-expanded' : 'is-collapsed'}`}>
<header onClick={() => setExpanded(value => !value)}>
<span className="client-info-card-heading"><h2>{title}</h2><span className="client-info-card-primary">— {meta}</span></span>
<button className="client-info-card-toggle" type="button" aria-expanded={expanded} aria-controls={detailsId}
aria-label={`${expanded ? 'Hide' : 'Show'} details for ${title}`} title={`${expanded ? 'Hide' : 'Show'} details`}>
<TriangleIcon direction={expanded ? 'down' : 'right'} />
</button>
</header>
{expanded && <div className="client-info-card-content" id={detailsId}><div className="client-info-card-details">{children}</div></div>}
</section>;
}
interface DashboardSourceScore {
tool: DashboardRiskTool;
records: FindingRecord[];
passed: number;
deviations: number;
assessed: number;
reviewRequired?: number;
reevaluationRequired?: number;
score?: number;
weight?: number;
rating?: SecuritySourceScore;
}
function DashboardSourceResults({ sources, snapshot }: { sources: DashboardSourceScore[]; snapshot?: EnvironmentSnapshot }) {
const [visibleRecords, setVisibleRecords] = useState(100);
const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US');
const records = sources.flatMap(source => source.records.map(record => ({ source, record })));
return <div className="client-env-evidence">
<section className="client-env-evidence-section">
<h3>Source result contributions</h3>
{sources.length ? <div className="client-env-evidence-scroll"><table><thead><tr>
<th scope="col">Tool</th><th scope="col">Results</th><th scope="col">Passed</th>
<th scope="col">Deviations</th><th scope="col">Assessed</th><th scope="col">Review required</th>
<th scope="col">Reevaluation required</th><th scope="col">Score</th><th scope="col">Weight</th>
<th scope="col">Scoring method</th><th scope="col">Issues</th>
</tr></thead><tbody>{sources.map(source => <tr key={source.tool.id}>
<td>{source.tool.label}</td><td>{quantity(source.records.length)}</td><td>{quantity(source.passed)}</td>
<td>{quantity(source.deviations)}</td><td>{quantity(source.assessed)}</td>
<td>{quantity(source.reviewRequired)}</td><td>{quantity(source.reevaluationRequired)}</td>
<td>{quantity(source.score)}</td><td>{quantity(source.weight)}</td>
<td>{source.rating?.methods.join(', ') || '—'}</td><td>{source.rating?.issues.join(' · ') || '—'}</td>
</tr>)}</tbody></table></div> : <p>No source results available.</p>}
</section>
{sources.flatMap(source => source.rating?.scans.map(scan => ({ source, scan })) ?? []).length > 0 && <section className="client-env-evidence-section">
<h3>Scan score evidence</h3>
<div className="client-env-evidence-scroll"><table><thead><tr>
<th scope="col">Tool</th><th scope="col">Method</th><th scope="col">Score</th>
<th scope="col">Native property</th><th scope="col">Native value</th><th scope="col">Passed</th>
<th scope="col">Deviations</th><th scope="col">Assessed</th><th scope="col">Eligible</th>
<th scope="col">Run IDs</th><th scope="col">Import file IDs</th><th scope="col">Issues</th>
</tr></thead><tbody>{sources.flatMap(source => source.rating?.scans.map(scan => ({ source, scan })) ?? [])
.map(({ source, scan }) => <tr key={scan.id}>
<td>{source.tool.label}</td><td>{scan.method}</td><td>{quantity(scan.score)}</td>
<td>{scan.nativeProperty ?? '—'}</td><td>{quantity(scan.nativeValue)}</td>
<td>{quantity(scan.passed)}</td><td>{quantity(scan.deviations)}</td><td>{quantity(scan.assessed)}</td>
<td>{quantity(scan.eligible)}</td><td>{scan.runIds.join(', ') || '—'}</td>
<td>{scan.importFileIds.join(', ') || '—'}</td><td>{scan.issues.join(' · ') || '—'}</td>
</tr>)}</tbody></table></div>
</section>}
{records.length > 0 && <section className="client-env-evidence-section">
<h3>Source finding records <span>{quantity(records.length)}</span></h3>
<div className="client-env-evidence-scroll"><table><thead><tr>
<th scope="col">Tool</th><th scope="col">Finding</th><th scope="col">Record ID</th>
<th scope="col">Original fields</th><th scope="col">Filled fields</th><th scope="col">Metadata</th>
<th scope="col">Scan run</th><th scope="col">Import file</th><th scope="col">Source sheet</th>
<th scope="col">Source file</th><th scope="col">Policy source evidence</th><th scope="col">Complete retained record</th>
</tr></thead><tbody>{records.slice(0, visibleRecords).map(({ source, record }) => <tr key={`${source.tool.id}:${record.id}`}>
<td>{source.tool.label}</td><td>{record.title}</td><td>{record.id}</td>
<td>{JSON.stringify(record.fields)}</td><td>{JSON.stringify(record.filled)}</td><td>{JSON.stringify(record.meta)}</td>
<td>{record.scanRunId ?? '—'}</td><td>{record.importFileId ?? '—'}</td>
<td>{record.sourceSheet ?? '—'}</td><td>{record.sourceFile ?? '—'}</td>
<td>{record.policySourceEvidence ? JSON.stringify(record.policySourceEvidence) : '—'}</td>
<td>{JSON.stringify(record)}</td>
</tr>)}</tbody></table></div>
{visibleRecords < records.length && <button className="btn" type="button" onClick={() => setVisibleRecords(value => value + 100)}>
Show more findings ({quantity(visibleRecords)} of {quantity(records.length)})
</button>}
</section>}
<EnvironmentEvidenceTables snapshot={snapshot} group="all"/>
</div>;
}
interface DashboardDomainScore {
id: string;
label: string;
glyph: DashboardGlyphName;
score?: number;
passed: number;
deviations: number;
notConfigured: number;
conflicts: number;
duplicates: number;
assessed: number;
records: FindingRecord[];
reviewRecords: FindingRecord[];
}
export function dashboardOverallScore(sourceScores: Array<{ score?: number; weight?: number }>): number | undefined {
return weightedSecurityScore(sourceScores);
}
function dashboardFieldValue(record: FindingRecord, pattern: RegExp): string | undefined {
return record.fields.find(field => pattern.test(field.label.trim()))?.value;
}
function dashboardObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string): number | undefined {
const observation = snapshot?.observations.find(item => item.property === property && item.state === 'observed');
const value = observation?.normalizedValue ?? observation?.originalValue;
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : undefined;
}
function dashboardEnvironmentTableNumber(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, header: RegExp): number | undefined {
const values = snapshot?.sections
.filter(section => section.sectionKey === sectionKey)
.flatMap(section => section.tables.flatMap(table => {
const column = table.headers.findIndex(value => header.test(value.trim()));
if (column < 0) return [];
return table.rows.flatMap(row => {
const numeric = Number(row[column]);
return Number.isFinite(numeric) ? [numeric] : [];
});
})) ?? [];
return values.length ? values.reduce((sum, value) => sum + value, 0) : undefined;
}
/**
* Zahl der Domaenen. Nennt eine Quelle sie ausdruecklich (Purple Knight:
* "AD: number of domains"), gilt diese Angabe; sonst bleibt die Zahl der
* verschiedenen beobachteten Domaenennamen die belegbare Untergrenze.
*/
function dashboardEnvironmentDomainCount(snapshot: EnvironmentSnapshot | undefined): number | undefined {
const declared = dashboardObservationNumber(snapshot, 'domain.count');
if (declared !== undefined) return declared;
const domains = new Set(snapshot?.observations
.filter(item => item.property === 'domain.fqdn' && item.state === 'observed')
.map(item => String(item.normalizedValue ?? item.originalValue).trim().toLocaleLowerCase('en-US'))
.filter(Boolean));
return domains.size || undefined;
}
/**
* Zahl der erfassten Gruppenrichtlinien aus den verarbeiteten Sicherungen.
*
* Die frueher benutzte Zaehlung der GUIDs aus Findingtexten war eine
* Schaetzung: sie fand nur GPOs, die ueberhaupt einen Befund tragen, und
* zaehlte jede sonstige GUID mit. Die Verarbeitungsliste nennt die Zahl
* dagegen selbst.
*/
function useGpoInventory(projectId?: string): { gpoCount?: number; snapshots?: number; records?: GpoBackupStatusRecord[] } {
const [inventory, setInventory] = useState<{ gpoCount?: number; snapshots?: number; records?: GpoBackupStatusRecord[] }>({});
useEffect(() => {
let active = true;
setInventory({});
if (!projectId) return () => { active = false; };
assessorApi.listProjectGpoBackupStatus(projectId)
.then(response => {
if (!active) return;
const processed = response.records.filter(record => record.processingStatus);
const gpoCount = processed.reduce((sum, record) => sum + record.gpoCount, 0);
setInventory(processed.length ? { gpoCount, snapshots: processed.length, records: processed } : {});
})
.catch(() => { if (active) setInventory({}); });
return () => { active = false; };
}, [projectId]);
return inventory;
}
function dashboardGpoCount(records: FindingRecord[]): number | undefined {
const identifiers = new Set<string>();
const pattern = /\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?/gi;
records.forEach(record => [record.sourceFile, ...record.fields.map(field => field.value)].filter(Boolean).forEach(value => {
String(value).match(pattern)?.forEach(identifier => identifiers.add(identifier.toLocaleLowerCase('en-US')));
}));
return identifiers.size || undefined;
}
/**
* Verbindungskurven zwischen Overall Score und den Security Domains.
*
* Die Kurven entstehen aus der tatsaechlichen Lage von Ring und Domainknoten,
* nicht aus angenommenen Koordinaten: Zuvor lagen feste Zielpunkte in einem
* ungleichmaessig gestreckten Koordinatensystem (`preserveAspectRatio="none"`),
* sodass die Linien nur bei genau einer Fenstergroesse an ihrem Knoten
* ankamen und sonst daneben endeten.
*/
export function DashboardPostureConnectors({ nodeCount }: { nodeCount: number }) {
// Gemessen wird ab dem eigenen Element: der Ref des umgebenden Abschnitts ist
// waehrend des Layouteffekts eines Kindes noch nicht gesetzt.
const svgRef = useRef<SVGSVGElement | null>(null);
const [geometry, setGeometry] = useState<{ width: number; height: number; paths: string[] }>({ width: 0, height: 0, paths: [] });
const measure = useCallback(() => {
const posture = svgRef.current?.parentElement;
const ring = posture?.querySelector('.client-overall-ring');
const nodes = [...(posture?.querySelectorAll('.client-domains .client-domain-score') ?? [])];
if (!posture || !ring || !nodes.length) {
setGeometry(current => (current.paths.length ? { width: 0, height: 0, paths: [] } : current));
return;
}
const frame = posture.getBoundingClientRect();
const ringBox = ring.getBoundingClientRect();
const start = { x: ringBox.right - frame.left, y: ringBox.top + ringBox.height / 2 - frame.top };
const paths = nodes.map((node, index) => {
const box = node.getBoundingClientRect();
const end = { x: box.left - frame.left, y: box.top + box.height / 2 - frame.top };
// Leichte Faecherung am Ring, damit die Kurven nicht als eine Linie
// starten; die Ankunft trifft die Knotenmitte exakt.
const originY = start.y + (index - (nodes.length - 1) / 2) * 6;
const reach = Math.max(24, (end.x - start.x) * 0.45);
return `M${start.x.toFixed(1)} ${originY.toFixed(1)} C${(start.x + reach).toFixed(1)} ${originY.toFixed(1)}, ${(end.x - reach).toFixed(1)} ${end.y.toFixed(1)}, ${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
});
setGeometry(current => current.width === frame.width && current.height === frame.height
&& current.paths.length === paths.length && current.paths.every((path, index) => path === paths[index])
? current : { width: frame.width, height: frame.height, paths });
}, []);
useLayoutEffect(() => {
measure();
const posture = svgRef.current?.parentElement;
// Zwei Ausloeser mit unterschiedlicher Reichweite: der Beobachter meldet
// jede Groessenaenderung der beteiligten Flaechen, das Fensterereignis
// greift auch dann, wenn die Seite gerade nicht gezeichnet wird und der
// Beobachter deshalb keine Rueckrufe liefert.
window.addEventListener('resize', measure);
const observer = posture && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measure()) : undefined;
if (posture && observer) {
observer.observe(posture);
posture.querySelectorAll('.client-domains, .client-overall').forEach(element => observer.observe(element));
}
return () => {
window.removeEventListener('resize', measure);
observer?.disconnect();
};
}, [measure, nodeCount]);
return <svg
ref={svgRef}
className="client-posture-connectors"
viewBox={`0 0 ${Math.max(1, geometry.width)} ${Math.max(1, geometry.height)}`}
aria-hidden="true"
>
{geometry.paths.map((path, index) => <path key={index} d={path} />)}
</svg>;
}
function dashboardScoreTone(score?: number): 'unknown' | 'critical' | 'medium' | 'passed' {
return score === undefined ? 'unknown' : score < 50 ? 'critical' : score < 75 ? 'medium' : 'passed';
}
export function DashboardOverallRing({ score, onClick }: { score?: number; onClick?(): void }) {
const value = score ?? 0;
const scoreLabel = score !== undefined && score > 0 && score < 0.5 ? '<1' : Math.round(value).toString();
const tone = dashboardScoreTone(score);
const label = `Overall Score: ${score === undefined ? 'No data' : scoreLabel}`;
const content = <>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="client-overall-ring-track" cx="60" cy="60" r="48" pathLength="100" />
{score !== undefined && <><path className="client-overall-ring-value" d={donutArcPath(value)} /><DonutSmallArcMarker percentage={value}/></>}
</svg>
<span><span className="client-overall-ring-number"><strong>{score === undefined ? '—' : scoreLabel}</strong>{score !== undefined && <small>/100</small>}</span><em>ASSESSOR</em></span>
</>;
return onClick
? <button className={`client-overall-ring tone-${tone}`} type="button" aria-label={`Open remediation overview in Work. ${label}`} onClick={onClick}>{content}</button>
: <div className={`client-overall-ring tone-${tone}`} role="img" aria-label={label}>{content}</div>;
}
export function DashboardTopFact({ value, label, glyph, snapshot, evidenceGroup, backupRecords, fallback }: {
value?: number | string; label: string; glyph: DashboardGlyphName; snapshot?: EnvironmentSnapshot;
evidenceGroup: EnvironmentEvidenceGroup; backupRecords?: GpoBackupStatusRecord[]; fallback?: string;
}) {
const evidence = environmentEvidenceForGroup(snapshot, evidenceGroup);
return <EnvironmentResultsButton title={label} triggerClassName="client-data-fact"
trigger={<><DashboardGlyph name={glyph}/><span><strong>{value ?? '—'}</strong><small>{label}</small></span></>}>
<DashboardFactList items={[{ label, value: value === undefined ? UNKNOWN_FACT : String(value) }]}/>
{fallback && <p className="client-env-evidence-empty">{fallback}</p>}
{!!evidence.observations.length && <table className="client-data-fact-evidence"><thead><tr><th>Field</th><th>Original value</th><th>Source</th><th>Scan run</th></tr></thead>
<tbody>{evidence.observations.map(item => <tr key={item.id}><td>{item.label}</td><td>{typeof item.originalValue === 'string' ? item.originalValue : JSON.stringify(item.originalValue)}</td><td>{item.tool.name}</td><td>{item.scanRunId}</td></tr>)}</tbody>
</table>}
{!!backupRecords?.length && <><p className="client-env-evidence-empty">All processed client backups · the overview count includes every backup listed below.</p>
<table className="client-data-fact-evidence"><thead><tr><th>Backup file</th><th>GPOs</th><th>Processing status</th><th>Updated</th></tr></thead>
<tbody>{backupRecords.map(record => <tr key={record.artifactId}><td>{record.fileName}</td><td>{record.gpoCount}</td><td>{record.processingStatus}</td><td>{record.updatedAt}</td></tr>)}</tbody>
</table></>}
<EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/>
</EnvironmentResultsButton>;
}
function dashboardDomainAmount(domain: DashboardDomainScore): number {
return domain.assessed === 0 && domain.reviewRecords.length > 0 ? domain.reviewRecords.length : domain.deviations;
}
function DashboardDomainRow({ domain, distributionTotal, onClick }: { domain: DashboardDomainScore; distributionTotal: number; onClick?(): void }) {
// Der Knoten traegt die Bestehensquote der Domaene. Ohne
// bewertete Ergebnisse bleibt das Sinnbild stehen - eine Null waere hier
// eine Aussage, die die Datenlage nicht deckt.
const tone = domain.score === undefined ? 'unknown' : domain.score < 50 ? 'critical' : domain.score < 75 ? 'medium' : 'passed';
const reviewOnly = domain.assessed === 0 && domain.reviewRecords.length > 0;
const amount = dashboardDomainAmount(domain);
const content = <>
<span className="client-domain-amount-bar" aria-hidden="true"><i style={shareStyle(share(amount, distributionTotal))} /></span>
<span className={`client-domain-score tone-${tone}`} title={domain.score === undefined ? 'Not assessed' : `Pass rate ${Math.round(domain.score)}%`}>
{domain.score === undefined ? <DashboardGlyph name={domain.glyph}/> : <em>{Math.round(domain.score)}</em>}
</span>
<span className="client-domain-copy"><strong>{domain.label}</strong></span>
<b title={reviewOnly ? 'Review required' : 'Deviations'}>{amount.toLocaleString('en-US')}</b>
</>;
return <li>{onClick ? <button type="button" onClick={onClick}>{content}</button> : <div>{content}</div>}</li>;
}
const impactLabels = Object.fromEntries(businessImpactControls.map(theme => [theme.id, theme.label]));
export function DashboardBusinessImpact({ distribution, loading, onSelect }: {
distribution?: ReturnType<typeof managementFindingDistribution>; loading: boolean; onSelect?: (themeId: string) => void;
}) {
const titleId = useId();
const max = Math.max(1, ...(distribution?.themes.map(theme => theme.count) ?? []));
return <section className="client-business-impact" aria-labelledby={titleId}>
<h3 id={titleId}>Possible Business Impact</h3>
{distribution ? <div className="client-business-impact-grid" role="list">
{distribution.themes.map(theme => <div role="listitem" key={theme.id} data-theme={theme.id}>
<button type="button" className="client-business-impact-column" disabled={!onSelect || theme.count === 0}
onClick={() => onSelect?.(theme.id)} aria-label={`${impactLabels[theme.id]}: ${theme.count}`}>
<strong>{theme.count.toLocaleString('en-US')}</strong>
<span className="client-business-impact-track" aria-hidden="true"><i style={shareStyle(share(theme.count, max))} /></span>
<span className="client-business-impact-label">{impactLabels[theme.id]}</span>
</button>
</div>)}
</div> : <p role="status">{loading ? 'Loading Business Impact…' : 'Business Impact is unavailable for this assessment.'}</p>}
</section>;
}
export interface DashboardNativeMetrics {
pingCastleRisk?: number;
purpleKnightPosture?: number;
purpleKnightIoes?: number;
benchmarkScore?: number;
assessedHost?: string;
domainCount?: number;
gpoCount?: number;
}
function nativeMeta(parts: Array<string | undefined>): string {
return parts.filter(Boolean).join(' · ');
}
/**
* Jede Quellenzeile nennt zuerst ihren vergleichbaren ASSESSOR-Score. Die
* werkzeugeigene Zahl steht daneben und immer mit ihrem Namen - eine
* PingCastle-Risikozahl (hoch ist schlecht) darf nie als Score gelesen werden.
*/
function DashboardSourceListItem({ source, native, onClick }: { source: DashboardSourceScore; native: DashboardNativeMetrics; onClick?(): void }) {
const normalized = source.tool.label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '');
const kind = normalized.includes('pingcastle') ? 'pingcastle' : normalized.includes('purple') ? 'purple' : normalized.includes('maester') ? 'maester' : normalized.includes('cis') ? 'cis' : normalized.includes('gpo') ? 'gpo' : 'other';
const score = source.score;
const tone = score === undefined ? 'unknown' : score < 50 ? 'critical' : score < 75 ? 'medium' : 'passed';
const assessorScore = { value: score === undefined ? '—' : Math.round(score), unit: '/100 ASSESSOR' };
const nativeScans = source.rating?.scans.filter(scan => scan.nativeValue !== undefined) ?? [];
const exactNative = nativeScans.length === 1 ? nativeScans[0].nativeValue : undefined;
const scoreMethod = source.reevaluationRequired && !source.assessed ? 'Re-evaluation required'
: source.reviewRequired && !source.assessed ? 'Review required'
: source.rating?.methods.length === 1
? ({ native: 'Native score', 'inverted-native': '100 − native risk', 'pass-rate': 'Pass rate', unavailable: 'Not scoreable' })[source.rating.methods[0]]
: source.rating ? 'Combined scan methods' : 'Pass rate';
const domains = native.domainCount === undefined ? undefined : `${native.domainCount} ${native.domainCount === 1 ? 'domain' : 'domains'}`;
const presentation = kind === 'pingcastle'
? { name: 'PingCastle', glyph: 'scan' as const, ...assessorScore,
meta: nativeMeta([exactNative === undefined ? undefined : `PingCastle risk ${exactNative}/100`, domains]) || 'Domain assessment' }
: kind === 'purple' ? { name: 'Purple Knight', glyph: 'shield' as const, ...assessorScore,
meta: nativeMeta([
exactNative === undefined ? undefined : `Posture ${exactNative}/100`,
`${source.deviations} deviations`
]) || 'Identity indicators' }
: kind === 'maester' ? { name: 'Maester', glyph: 'cloud' as const, ...assessorScore,
meta: `${source.passed.toLocaleString('en-US')} / ${source.assessed.toLocaleString('en-US')} tests passed` }
: kind === 'cis' ? { name: 'CIS-CAT', glyph: 'clipboard' as const, ...assessorScore,
meta: nativeMeta([
exactNative === undefined ? undefined : `Benchmark score ${exactNative}`,
`${source.assessed.toLocaleString('en-US')} controls`
]) }
: kind === 'gpo' ? { name: 'GPO Analysis', glyph: 'domain' as const, ...assessorScore,
meta: nativeMeta([source.reevaluationRequired ? `${source.reevaluationRequired.toLocaleString('en-US')} saved comparisons`
: source.reviewRequired && !source.assessed ? `${source.reviewRequired.toLocaleString('en-US')} comparisons require review`
: `${source.deviations.toLocaleString('en-US')} baseline deviations`, `${native.gpoCount ?? '—'} GPOs`]) }
: { name: source.tool.label, glyph: 'policy' as const, ...assessorScore, meta: `${source.assessed.toLocaleString('en-US')} controls assessed` };
const content = <><DashboardGlyph name={presentation.glyph}/><span>{presentation.name}</span><strong>{presentation.value}<small>{presentation.unit}</small></strong><em>{scoreMethod} · {presentation.meta}{source.rating?.issues.length ? ' · Provisional' : ''}</em></>;
return <li>{onClick
? <button className={`client-source-${kind} tone-${tone}`} type="button" onClick={onClick}>{content}</button>
: <div className={`client-source-${kind} tone-${tone}`}>{content}</div>}
</li>;
}
const NO_RECORDS: FindingRecord[] = [];
/**
* Auswertung der geladenen Ergebnisse fuer die Client Info.
*
* Ein Mandantenblatt traegt hier mehrere tausend Datensaetze. Die Ansicht
* braucht davon Quellen-, Domain- und Prioritaetssichten gleichzeitig; jede
* einzeln berechnet las dieselben Datensaetze erneut und baute je Domain den
* Klassifikationstext neu auf. Bei acht Domains, fuenf Quellen und vierzehn
* Punkteberechnungen waren das mehrere hunderttausend Zugriffe - und zwar bei
* jedem Rendern, also auch bei jeder Zustandsaenderung der Anwendung.
*
* Deshalb entsteht die gesamte Ableitung hier in einem Durchlauf und nur dann
* neu, wenn sich Daten, Profil, Reviews, Facetten oder Quellenauswahl aendern.
* Zustand, Schweregrad und Domainzuordnung je Datensatz werden dabei einmal
* bestimmt und anschliessend nur noch nachgeschlagen.
*/
type DashboardAnalysis = ReturnType<typeof dashboardAnalysis>;
/**
* Die Auswertung ueberlebt den Ansichtswechsel. Ein `useMemo` gilt nur fuer die
* Lebensdauer der Komponente; Client Info wird beim Wechsel abgebaut und beim
* Zurueckkehren neu aufgebaut, sodass dieselbe Rechnung sonst bei jedem Besuch
* erneut liefe.
*/
const analysisCache = new WeakMap<object, {
dataset: ImportedDataset;
profile: ImportProfile;
reviews: Record<string, Review>;
sourceFilter?: DynamicFacetFilter;
value: DashboardAnalysis;
}>();
/**
* Facetten und Auswertung entstehen gemeinsam und werden gemeinsam behalten.
*
* Die Facettenliste wird bei jedem Aufbau der Ansicht neu erzeugt; sie taugt
* deshalb nicht als Schluessel eines ansichtsuebergreifenden Zwischenspeichers.
* Massgeblich sind die Eingaben, aus denen beides entsteht: Blatt, Datensatz,
* Profil und Reviews.
*/
function useDashboardAnalysis(
activeSheet: ReturnType<typeof useApp>['activeSheet'],
dataset: ImportedDataset | null | undefined,
activeProfile: ImportProfile,
reviews: Record<string, Review>,
sourceFilter?: DynamicFacetFilter
) {
return useMemo(() => {
const cached = activeSheet && dataset ? analysisCache.get(activeSheet) : undefined;
if (cached && cached.dataset === dataset && cached.profile === activeProfile && cached.reviews === reviews && cached.sourceFilter === sourceFilter) {
return cached.value;
}
const facets = activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : [];
const value = dashboardAnalysis(activeSheet, activeProfile, reviews, facets, sourceFilter, dataset?.securityScoring);
if (activeSheet && dataset) analysisCache.set(activeSheet, { dataset, profile: activeProfile, reviews, sourceFilter, value });
return value;
}, [activeProfile, activeSheet, dataset, reviews, sourceFilter]);
}
export function dashboardAnalysis(
activeSheet: ReturnType<typeof useApp>['activeSheet'],
activeProfile: ImportProfile,
reviews: Record<string, Review>,
facets: DynamicFacet[],
sourceFilter?: DynamicFacetFilter,
securityScoring?: SecurityScoreContext
) {
{
const sourceFacet = facets.find(facet =>
/^(?:quelle|source):\s*scan$/i.test(facet.label.trim()) || facet.source === 'provenance');
const sourceFilters = sourceFacet && sourceFilter?.values?.length ? { [sourceFacet.id]: sourceFilter } : undefined;
const allRecords = activeSheet?.analysis.records ?? NO_RECORDS;
const records = sourceFilters ? allRecords.filter(record => matchesDynamicFacets(record, [sourceFacet!], sourceFilters, reviews)) : allRecords;
const countedFacets = sourceFilters ? recountFacetsAgainstActiveFilters({
facets, records, filters: {}, reviews, statusFilter: 'all',
matchesStatus: (record, status) => activeProfile.matchesStatus(record, status)
}) : facets;
const statusFacet = countedFacets.find(facet => facet.source === 'profile-status');
const severityFacet = countedFacets.find(facet => facet.severityScale);
const states = new Map<FindingRecord, DashboardResultState>();
records.forEach(record => {
const state = dashboardResultState(record, activeProfile);
states.set(record, state);
});
const domainBuckets = dashboardDomainAssignment(records, activeSheet?.toolId);
const scoreOf = (subset: FindingRecord[]) =>
dashboardScoreFromStates(subset.map(record => states.get(record) ?? 'unknown'), subset);
const tools = dashboardRiskTools(sourceFacet?.values ?? []);
const sourceScores: DashboardSourceScore[] = dashboardSecuritySources(tools.map(tool => {
const toolRecords = sourceFacet
? records.filter(record => matchesDynamicFacets(record, [sourceFacet], { [sourceFacet.id]: { values: tool.sourceValues } }, reviews))
: NO_RECORDS;
return { tool, records: toolRecords, ...scoreOf(toolRecords) };
}).filter(source => source.records.length > 0), securityScoring, Boolean(activeSheet?.isProjectSheet && !sourceFilters));
const domains: DashboardDomainScore[] = securityDomainCatalog.map(domain => {
const domainRecords = domainBuckets.get(domain.id) ?? NO_RECORDS;
const score = scoreOf(domainRecords);
return {
id: domain.id,
label: domain.label,
glyph: domain.glyph,
// The clickable population is exactly the number displayed. Passed
// and contextual GPO states remain visible only as secondary counts.
records: domainRecords.filter(record => states.get(record) === 'deviation'),
reviewRecords: domainRecords.filter(record => ['review-required', 'reevaluation-required'].includes(states.get(record) ?? '')),
...score
};
});
const totalResult = dashboardScoreFromStates(records.map(record => states.get(record) ?? 'unknown'), records);
const gapRecordIds = records.filter(record => !['passed', 'deviation', 'not-applicable'].includes(states.get(record) ?? 'unknown'))
.map(record => record.id);
return {
total: records.length,
records,
statusFacet,
severityFacet,
sourceFacet,
tools,
reports: sourceFacet?.values.length ?? 0,
statusCount: (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0,
sourceScores,
domains,
totalResult,
gapRecordIds,
overallScore: dashboardOverallScore(sourceScores)
};
}
}
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const app = useApp();
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender' | 'compliance' | 'ai'>('overview');
const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]);
const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID;
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []);
const showAiOverview = aiOverview.available && Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run);
// Compliance Engine tab: shown while the engine is on and the backend can deliver the timeslot's ISO overview.
const showCompliance = useEnginePreferences().compliance && app.backendStatus === 'connected' && Boolean(app.targetProjectId)
&& Boolean(app.activeAssessmentIds?.length || app.activeScanRunIds?.length);
useEffect(() => {
if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender');
else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('overview');
else if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView(pureDefender ? 'defender' : 'overview');
else if (clientInfoView === 'compliance' && !showCompliance) setClientInfoView(pureDefender ? 'defender' : 'overview');
}, [clientInfoView, hasDefender, pureDefender, showAiOverview, showCompliance]);
const gpoInventory = useGpoInventory(app.targetProjectId);
const sourceFilter = app.facetFilters['provenance:source-scan'];
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
const engines = useEnginePreferences();
const impactContext = useAssessmentContext(Boolean(dataset) && engines.report, app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset);
const impactDistribution = useMemo(() => {
if (impactContext.status !== 'ready') return undefined;
const keys = managementWorkspaceDeviationKeys(analysis.records, record => dashboardResultState(record, activeProfile) === 'deviation');
return managementFindingDistribution(publishContextReport(impactContext.context), analysis.totalResult.deviations, keys);
}, [impactContext.status === 'ready' ? impactContext.context : undefined, analysis, activeProfile]);
const [reportExporting, setReportExporting] = useState<'management' | 'technical' | null>(null);
const [reportError, setReportError] = useState<string | null>(null);
const reportScopeKey = JSON.stringify([app.targetProjectId, app.activeScanRunIds, app.activeAssessmentIds]);
const reportScopeRef = useRef(reportScopeKey);
reportScopeRef.current = reportScopeKey;
const exportReport = async (kind: 'management' | 'technical') => {
const projectId = app.targetProjectId;
if (!readEnginePreferences().report || !projectId || !dataset || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)) return;
if (kind === 'management' && !readEnginePreferences().context) {
setReportError('Management Report requires Context Engine. Enable it in Engines and try again.');
return;
}
const requestedScope = reportScopeKey;
setReportExporting(kind);
setReportError(null);
try {
// The separate Report Engine renders and archives on the server; the download is the archived file.
const run = await assessorApi.createReportRun(projectId, {
kind, scanRunIds: app.activeScanRunIds, assessmentIds: app.activeAssessmentIds
});
if (!readEnginePreferences().report || (kind === 'management' && !readEnginePreferences().context) || reportScopeRef.current !== requestedScope) return;
downloadText(browserDownloadAdapter, run.fileName, run.html, 'text/html;charset=utf-8');
} catch (reason) {
if (readEnginePreferences().report) setReportError(reason instanceof Error ? reason.message : 'Report could not be generated.');
} finally {
setReportExporting(null);
}
};
const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]);
const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]);
const [scoringOpen, setScoringOpen] = useState(false);
// Ersatzzaehlung aus Findingtexten, solange die Verarbeitungsliste der
// Sicherungen noch nicht geantwortet hat. Sie liest jeden Feldwert der
// GPO-Datensaetze und darf deshalb nicht bei jedem Rendern erneut laufen.
const derivedGpoCount = useMemo(
() => dashboardGpoCount(analysis.sourceScores.find(source => /gpo/i.test(source.tool.label))?.records ?? []),
[analysis]
);
if (!dataset || !activeSheet) return app.targetProjectId
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
: <EmptyWorkspace />;
const jump = (apply: () => void, preserveSource = true) => {
resetFilters();
if (preserveSource && sourceFilter && analysis.sourceFacet) setFacetFilter(analysis.sourceFacet.id, sourceFilter);
app.setActionOnly(false);
setStatusFilter('deviation');
apply();
setView('columns');
onOpenWork();
};
const clientInfoViews: Array<readonly ['overview' | 'defender' | 'compliance' | 'ai', string]> = [
...(pureDefender ? [] : [['overview', 'Overview'] as const]),
...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []),
...(showCompliance ? [['compliance', 'Compliance'] as const] : []),
...(showAiOverview ? [['ai', 'AI Overview'] as const] : [])
];
const clientInfoTabs = clientInfoViews.length > 1 ? <nav className="client-info-tabs" aria-label="Client Info views">
{clientInfoViews.map(([id, title]) =>
<button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)}
</nav> : undefined;
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />;
// Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work.
if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls navigation={clientInfoTabs} projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] });
}, false)}
onOpenTheme={theme => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] });
}, false)}/>;
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}
onScopedRiskAcceptance={app.backendStatus === 'connected' ? write => app.recordScopedRiskAcceptance(write) : undefined} />;
}
const {
total, statusFacet, severityFacet, sourceFacet, tools, reports,
sourceScores, domains, totalResult, gapRecordIds, overallScore
} = analysis;
const deviations = totalResult.deviations;
const assessmentCoverage = totalResult.coverage;
const nativePingCastleRisk = dashboardObservationNumber(environment.snapshot, 'risk.domain')
?? dashboardObservationNumber(environment.snapshot, 'risk.global');
const userAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionUserInformation', /^nb user accounts$/i);
const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i);
const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count');
// Give repeated overview facts one home; Results retains the full category.
// Compare only the same named fact, never unrelated facts with equal values.
const countOverview = new Map([
['Domains', numberFact(dashboardEnvironmentDomainCount(environment.snapshot))],
['Domain controllers', numberFact(domainControllers)],
['User accounts', numberFact(userAccounts)],
['Computer objects', numberFact(computerAccounts)]
]);
const alreadyInData = (item: DashboardFact) => countOverview.get(item.label) === item.value;
const domainOverview = domainInformation.filter(item => !alreadyInData(item));
const inventoryOverview = environmentDetail.inventory.filter(item =>
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
const entraTenants = dashboardEntraTenantCount(environment.snapshot)
?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected')
?? (maesterSource?.records.length ? 1 : undefined);
const gpoCount = gpoInventory.gpoCount ?? derivedGpoCount;
const nativeMetrics: DashboardNativeMetrics = {
pingCastleRisk: nativePingCastleRisk,
purpleKnightPosture: dashboardObservationNumber(environment.snapshot, 'purple_knight.ad.security_posture_score'),
purpleKnightIoes: dashboardObservationNumber(environment.snapshot, 'purple_knight.ad.ioes_found'),
benchmarkScore: dashboardObservationNumber(environment.snapshot, 'benchmark.score'),
assessedHost: observationFor(environment.snapshot, 'host.name') ? observationFact(environment.snapshot, 'host.name') : undefined,
domainCount: dashboardEnvironmentDomainCount(environment.snapshot),
gpoCount
};
const sourceOrder = ['pingcastle', 'purpleknight', 'maester', 'cis-cat', 'gpos'];
const orderedSourceScores = sourceScores.filter(source =>
source.tool.label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '') !== 'tabulardata'
).sort((left, right) => {
const rank = (label: string) => {
const normalized = label.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '');
const index = sourceOrder.findIndex(item => normalized.includes(item.replace(/[^a-z0-9]+/g, '')));
return index < 0 ? sourceOrder.length : index;
};
return rank(left.tool.label) - rank(right.tool.label) || left.tool.label.localeCompare(right.tool.label);
});
const displayedDomains = domains;
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
const overallTone = dashboardScoreTone(overallScore);
const sourceOverviewContent = (dismissResults: () => void) => orderedSourceScores.length ? <ul>{orderedSourceScores.map(source =>
<DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics}
onClick={sourceFacet && source.tool.sourceValues.length ? () => { dismissResults(); jump(() => {
setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues });
// The source drilldown includes saved GPO review/conflict/duplicate rows.
if (/gpo/i.test(source.tool.label)) setStatusFilter('all');
}); } : undefined} />
)}</ul> : <p className="dashboard-empty">No source results available.</p>;
const selectedSource = sourceFilter?.values?.length
? tools.find(tool => tool.sourceValues.length === sourceFilter.values!.length
&& tool.sourceValues.every(value => sourceFilter.values!.includes(value)))?.id ?? 'selection'
: 'all';
return (
<div className="dashboard-page client-command-center">
{clientInfoTabs}
<header className="client-overview-header">
<div className="client-overview-identity"><span>Client:</span><strong>{dataset.fileName}</strong></div>
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl /></div>
<label className="client-overview-source"><span>Source:</span><select value={selectedSource} onChange={event => {
const tool = tools.find(candidate => candidate.id === event.currentTarget.value);
if (!sourceFacet) return;
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) app.setActiveSheet(targetSheet.id);
setFacetFilter(sourceFacet.id, tool ? { values: tool.sourceValues } : null);
}}><option value="all">Scan · All</option>{selectedSource === 'selection' && <option value="selection">Scan · Selection</option>}{tools.map(tool => <option key={tool.id} value={tool.id}>{tool.label}</option>)}</select></label>
<div className="client-report-actions" aria-label="Assessment reports">
<button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('management')}>
{reportExporting === 'management' ? 'Creating Management Report…' : 'Management Report'}
</button>
<button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('technical')}>
{reportExporting === 'technical' ? 'Creating Technical Report…' : 'Technical Report'}
</button>
</div>
{!engines.report && <span>Report Engine is off</span>}
{engines.report && reportError && <p className="client-report-error" role="alert">{reportError}</p>}
</header>
<div className="client-overview-main">
<section className={`client-posture tone-${overallTone}`} aria-label="Security posture">
<DashboardPostureConnectors nodeCount={displayedDomains.length} />
<section className="client-overall" aria-labelledby="client-overall-heading">
<div className="client-overall-head">
<h2 id="client-overall-heading">Overall Score</h2>
<EnvironmentResultsButton title="Overall Score">{dismissResults => <>
<div className="client-environment-sources">{sourceOverviewContent(dismissResults)}</div>
{environmentDetail.restricted.length > 0 && <p className="client-environment-restricted">Restricted by the source tool, not empty: {environmentDetail.restricted.join(', ')}</p>}
<button className="btn" type="button" onClick={() => { dismissResults(); setScoringOpen(true); }}>Scoring methodology ↗</button>
<DashboardSourceResults sources={orderedSourceScores} snapshot={environment.snapshot}/>
</>}</EnvironmentResultsButton>
</div>
<DashboardOverallRing score={overallScore} onClick={() => jump(() => undefined, false)} />
<div className="client-overall-coverage">
<span><strong>Assessment Coverage</strong><b>{assessmentCoverage === undefined ? '—' : `${assessmentCoverage}%`}</b></span>
<i><u style={shareStyle(assessmentCoverage ?? 0)} /></i>
<small>{totalResult.assessed.toLocaleString('en-US')} / {totalResult.eligible.toLocaleString('en-US')} eligible controls assessed</small>
</div>
</section>
<section className="client-domains" aria-labelledby="client-domains-heading">
<header><h2 id="client-domains-heading">Security Domains</h2></header>
<ul>{displayedDomains.map(domain => <DashboardDomainRow key={domain.id} domain={domain} distributionTotal={domainDistributionTotal} onClick={domain.records.length
? () => jump(() => { setFacetFilter(SECURITY_DOMAIN_FACET_ID, { values: [domain.id] }); })
: domain.assessed === 0 && domain.reviewRecords.length ? () => jump(() => {
setStatusFilter('all');
setPathFindingIds(domain.reviewRecords.map(record => record.id));
}) : undefined} />)}</ul>
{engines.report && engines.context && <DashboardBusinessImpact distribution={impactDistribution} loading={impactContext.status === 'loading'}
onSelect={themeId => jump(() => {
const contextFilter = app.facetFilters[CONTEXT_CLUSTER_FACET_ID];
if (contextFilter) setFacetFilter(CONTEXT_CLUSTER_FACET_ID, contextFilter);
setFacetFilter(BUSINESS_IMPACT_FACET_ID, { values: [themeId] });
})} />}
</section>
</section>
<aside className="client-priority" aria-labelledby="client-priority-heading">
<section className="client-priority-findings">
<header><h2 id="client-priority-heading">Data</h2><DonutMetricCard className="datahub-reference-knob client-priority-total" label="Deviations" value={deviations} percentage={share(deviations, total)} tone="deviation" onClick={() => jump(() => undefined)} /></header>
<div className="client-priority-context" aria-label="Additional result states">
{([
['passed', 'Passed', totalResult.passed],
['not-configured', 'Not configured', totalResult.notConfigured],
['duplicates', 'Duplicates', totalResult.duplicates],
['conflicts', 'Conflicts', totalResult.conflicts]
] as const).map(([status, label, count]) => <button type="button" key={status} disabled={count === 0} onClick={() => jump(() => setStatusFilter(status))}><strong>{count.toLocaleString('en-US')}</strong><span>{label}</span></button>)}
<button type="button" disabled={totalResult.unassessed === 0} onClick={() => jump(() => {
setStatusFilter('all');
setPathFindingIds(gapRecordIds);
})}><strong>{totalResult.unassessed.toLocaleString('en-US')}</strong><span>Gaps</span></button>
</div>
<section className="client-data-facts" aria-label="Environment facts">
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" snapshot={environment.snapshot} evidenceGroup="ad-domains" />
<DashboardTopFact value={entraTenants} label="Entra Tenant" glyph="cloud" snapshot={environment.snapshot} evidenceGroup="entra-tenants" fallback={!dashboardEntraTenantCount(environment.snapshot) && !dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') && maesterSource?.records.length ? "Tenant presence inferred from loaded Maester results; no retained tenant identity is available." : undefined} />
<DashboardTopFact value={userAccounts} label="User Accounts" glyph="user" snapshot={environment.snapshot} evidenceGroup="user-accounts" />
<DashboardTopFact value={computerAccounts} label="Computer Objects" glyph="computer" snapshot={environment.snapshot} evidenceGroup="computer-objects" />
<DashboardTopFact value={domainControllers} label="Domain Controllers" glyph="server" snapshot={environment.snapshot} evidenceGroup="domain-controllers" />
<DashboardTopFact value={gpoCount} label="GPOs" glyph="policy" snapshot={environment.snapshot} evidenceGroup="gpos" backupRecords={gpoInventory.records} fallback={gpoCount !== undefined && !gpoInventory.records?.length ? "Count derived from GPO identifiers in the loaded findings; no processed backup inventory is available." : undefined} />
</section>
</section>
<section className="client-domain-information" aria-labelledby="client-domain-information-heading">
<header className="client-env-group-header">
<h2 id="client-domain-information-heading">Domain Information</h2>
<EnvironmentResultsButton title="Domain Information">
<DashboardFactList items={domainInformation}/>
<EnvironmentEvidenceTables snapshot={environment.snapshot} group="directory"/>
</EnvironmentResultsButton>
</header>
<dl>{domainOverview.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
</section>
</aside>
</div>
<ClientContextHeatmap onOpenCluster={clusterId => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
}, false)} />
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<div className="client-environment-grid" aria-label="Environment overview">
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" />
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} overviewItems={inventoryOverview} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" />
<DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" snapshot={environment.snapshot} evidenceGroup="password-policy" />
<DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" snapshot={environment.snapshot} evidenceGroup="certificates" />
<OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} snapshot={environment.snapshot} />
<CloudInformationTile snapshot={environment.snapshot} icon={<DashboardGlyph name="cloud"/>}
key={`cloud-information:${app.targetProjectId}:${app.activeAssessmentIds?.join(',')}:${app.activeScanRunIds?.join(',')}`}
scope={app.targetProjectId ? { projectId: app.targetProjectId, assessmentId: app.activeAssessmentIds?.length === 1 ? app.activeAssessmentIds[0] : undefined,
scanRunIds: app.activeScanRunIds } : undefined} />
</div>
<RiskRegister dataset={dataset} reviews={reviews} scopedRiskAcceptances={app.scopedRiskAcceptances} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
</div>
);
}