2.21.0: Client Info Compliance tab with ISO mapping history

The Compliance row moves from the Client Info Overview into its own
Compliance tab (before AI Overview). The framework tile row and its
Results button stay unchanged so later frameworks add a tile.

The tab heading carries a Last mapping button. Its window lists up to 50
mapping batches and single finding mappings of all clients, because
mappings are global per rule. Each entry shows its trigger (Automatic,
Manual, Single finding), where it came from relative to the shown
timeslot (This timeslot, Other timeslot, Other client, Unknown), status
and content-free counts. Mapping runs now record their trigger, and
single mappings their origin as SHA-256 tokens only, since runs are
exported with every client. The tab refreshes while a mapping runs and
shows the last overview at once when returning to it.

Also: history table keeps the 14px table role in every column, and the
flaky sheet-selection-cost test uses 300 instead of 2,537 sheets.

Core changes (released Presentation, Governance, Platform, Core Tests):
DashboardPanel Compliance tab, AGENTS.md decisions, product version
2.20.0 -> 2.21.0, sheet-selection-cost test.
This commit is contained in:
Binh 2026-10-08 11:16:23 +02:00
parent 5fd8f98014
commit 8649d01229
13 changed files with 460 additions and 87 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,9 +2,10 @@ import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import type { ComplianceReadPort } from '../../src/compliance/compliance-store'; import type { ComplianceReadPort } from '../../src/compliance/compliance-store';
import type { RegulatoryMappingBatchCreateRequest } from '../../src/compliance/regulatory-mapping'; import { AUTO_MAPPING_BATCH_KEY_PREFIXES, type RegulatoryMappingBatchCreateRequest } from '../../src/compliance/regulatory-mapping';
import type { RegulatoryMappingService } from './regulatory-mapping-service'; import type { RegulatoryMappingService } from './regulatory-mapping-service';
const [SCAN_KEY_PREFIX, GPO_KEY_PREFIX] = AUTO_MAPPING_BATCH_KEY_PREFIXES;
const DEFAULT_POLL_INTERVAL_MS = 10_000; const DEFAULT_POLL_INTERVAL_MS = 10_000;
const PAGE_SIZE = 500; const PAGE_SIZE = 500;
@ -97,7 +98,7 @@ export class RegulatoryAutoMapping {
this.knownScanRuns.add(run.id); this.knownScanRuns.add(run.id);
this.pending.set(`scan:${run.id}`, { projectId: run.projectId, request: { this.pending.set(`scan:${run.id}`, { projectId: run.projectId, request: {
// Stable per scan run, so a restart or a second pass never creates a duplicate batch. // Stable per scan run, so a restart or a second pass never creates a duplicate batch.
idempotencyKey: safeKey(`auto:${run.id}`), idempotencyKey: safeKey(`${SCAN_KEY_PREFIX}${run.id}`),
assessmentIds: [run.assessmentId], scanRunIds: [run.id], filters: { deviation: true } assessmentIds: [run.assessmentId], scanRunIds: [run.id], filters: { deviation: true }
} }); } });
} }
@ -108,7 +109,7 @@ export class RegulatoryAutoMapping {
// Stable per set of new GPO rows; one batch maps every GPO deviation of the timeslot. // Stable per set of new GPO rows; one batch maps every GPO deviation of the timeslot.
const fingerprint = createHash('sha256').update(added.sort().join('\n'), 'utf8').digest('hex').slice(0, 16); const fingerprint = createHash('sha256').update(added.sort().join('\n'), 'utf8').digest('hex').slice(0, 16);
this.pending.set(`gpo:${assessmentId}`, { projectId, request: { this.pending.set(`gpo:${assessmentId}`, { projectId, request: {
idempotencyKey: safeKey(`auto-gpo:${fingerprint}:${assessmentId}`), idempotencyKey: safeKey(`${GPO_KEY_PREFIX}${fingerprint}:${assessmentId}`),
assessmentIds: [assessmentId], scanRunIds: [], filters: { sourceKind: ['policy-evaluation'], deviation: true } assessmentIds: [assessmentId], scanRunIds: [], filters: { sourceKind: ['policy-evaluation'], deviation: true }
} }); } });
} }

View File

@ -29,7 +29,10 @@ import {
type RegulatoryControlOverviewItem, type RegulatoryControlOverviewItem,
type RegulatoryControlOverviewResponse, type RegulatoryControlOverviewResponse,
acceptedControlIds, acceptedControlIds,
regulatoryControlTheme regulatoryControlTheme,
AUTO_MAPPING_BATCH_KEY_PREFIXES,
MAPPING_HISTORY_LIMIT,
type RegulatoryMappingHistoryEntry
} from '../../src/compliance/regulatory-mapping'; } from '../../src/compliance/regulatory-mapping';
import type { RegulatoryRetrievalCandidate } from '../../src/compliance/regulatory-rag'; import type { RegulatoryRetrievalCandidate } from '../../src/compliance/regulatory-rag';
import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import type { UnifiedResultProjection } from '../../src/domain/unified-result';
@ -184,7 +187,7 @@ export class RegulatoryMappingService {
const finding = await this.finding(projectId, request.resultProjectionId); const finding = await this.finding(projectId, request.resultProjectionId);
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422); if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
if (!buildRegulatoryFindingQuery(finding)) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422); if (!buildRegulatoryFindingQuery(finding)) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
const claimed = await this.claim(finding, true); const claimed = await this.claim(finding, true, 'single', await this.origin(projectId, request.origin));
if (claimed.claimed) this.track(this.enqueue(() => this.process(claimed.run, finding)).then(() => undefined)); if (claimed.claimed) this.track(this.enqueue(() => this.process(claimed.run, finding)).then(() => undefined));
return this.get(projectId, request.resultProjectionId); return this.get(projectId, request.resultProjectionId);
} }
@ -311,10 +314,50 @@ export class RegulatoryMappingService {
version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size, version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size,
controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] })) controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] }))
.sort((left, right) => right.findings.length - left.findings.length .sort((left, right) => right.findings.length - left.findings.length
|| left.controlId.localeCompare(right.controlId, 'en', { numeric: true })) || left.controlId.localeCompare(right.controlId, 'en', { numeric: true })),
...(deviationsOnly ? { mappingHistory: await this.mappingHistory(projectId, scope) } : {})
}; };
} }
/**
* Newest first, globally (user decision 2026-10-08), because mappings and decisions are global per rule:
* every batch of every client and every single finding mapping, each marked by where it was started
* relative to the shown timeslot. Entries carry dates, trigger, that mark and counts only, never a client,
* rule or scope id. Runs from before 2.21.0 record no trigger; a run a batch started is counted by its batch.
*/
private async mappingHistory(projectId: string, scope: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryMappingHistoryEntry[]> {
const assessments = new Set(scope.assessmentIds);
const scanRuns = new Set(scope.scanRunIds);
const viewed = originTokens(projectId, scope);
const viewedTimeslot = new Set(viewed.timeslot);
const batchRuns = (await Promise.all((await this.read.listProjects())
.map(project => this.store.listRegulatoryMappingBatchRuns(project.id)))).flat();
const batches: RegulatoryMappingHistoryEntry[] = batchRuns.map(run => ({
id: run.id, kind: 'batch',
scope: run.projectId !== projectId ? 'other-client'
: run.assessmentIds.some(id => assessments.has(id)) || run.scanRunIds.some(id => scanRuns.has(id)) ? 'timeslot' : 'other-timeslot',
trigger: AUTO_MAPPING_BATCH_KEY_PREFIXES.some(prefix => run.idempotencyKey.startsWith(prefix)) ? 'automatic' : 'manual',
status: run.status, createdAt: run.createdAt, ...(run.completedAt ? { completedAt: run.completedAt } : {}),
counts: { findings: run.counts.eligibleFindingCount, sentToModel: run.counts.scheduledRuleCount,
reused: run.counts.reusedSucceededRuleCount + run.counts.reusedActiveRuleCount,
succeeded: run.counts.succeededRuleCount, failed: run.counts.failedRuleCount }
}));
// A single mapping maps one rule for every client; it has no finding count of its own.
const singles: RegulatoryMappingHistoryEntry[] = (await this.store.listRegulatoryMappingRuns())
.filter(run => run.trigger === 'single')
.map(run => ({
id: run.id, kind: 'single', trigger: 'manual',
scope: !run.origin ? 'unknown' : run.origin.timeslot.some(token => viewedTimeslot.has(token)) ? 'timeslot'
: run.origin.project === viewed.project ? 'other-timeslot' : 'other-client',
status: run.status === 'succeeded' ? 'completed' : run.status,
createdAt: run.createdAt, ...(run.completedAt ? { completedAt: run.completedAt } : {}),
counts: { sentToModel: 1, reused: 0, succeeded: run.status === 'succeeded' ? 1 : 0, failed: run.status === 'failed' ? 1 : 0 }
}));
return [...batches, ...singles]
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id))
.slice(0, MAPPING_HISTORY_LIMIT);
}
/** /**
* Accepts the first proposals of a finished mapping, in the model's order. A control that already has a * Accepts the first proposals of a finished mapping, in the model's order. A control that already has a
* decision keeps it, so a manual reject is never overridden; a failure here never fails the mapping. * decision keeps it, so a manual reject is never overridden; a failure here never fails the mapping.
@ -386,7 +429,7 @@ export class RegulatoryMappingService {
} }
} }
private queuedRun(finding: UnifiedResultProjection): RegulatoryMappingRun { private queuedRun(finding: UnifiedResultProjection, trigger: 'single' | 'batch', origin?: RegulatoryMappingRun['origin']): RegulatoryMappingRun {
const preparedQuery = buildRegulatoryFindingQuery(finding); const preparedQuery = buildRegulatoryFindingQuery(finding);
if (!preparedQuery) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422); if (!preparedQuery) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
const timestamp = this.now().toISOString(); const timestamp = this.now().toISOString();
@ -408,14 +451,26 @@ export class RegulatoryMappingService {
promptVersion: this.promptVersion(ruleKey), promptVersion: this.promptVersion(ruleKey),
outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION, outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION,
model: this.options.client!.model, model: this.options.client!.model,
trigger,
...(origin ? { origin } : {}),
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
candidates: [] candidates: []
}; };
} }
private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean) { private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean, trigger: 'single' | 'batch', origin?: RegulatoryMappingRun['origin']) {
return this.store.claimRegulatoryMappingRun(this.queuedRun(finding), forceRegeneration); return this.store.claimRegulatoryMappingRun(this.queuedRun(finding, trigger, origin), forceRegeneration);
}
/** The client always; the timeslot only when it is a valid timeslot of that client. */
private async origin(projectId: string, requested: RegulatoryMappingCreateRequest['origin']): Promise<RegulatoryMappingRun['origin']> {
let timeslot: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'> = { assessmentIds: [], scanRunIds: [] };
if (requested) {
try { timeslot = await this.validateBatchScope(projectId, { assessmentIds: requested.assessmentIds, scanRunIds: requested.scanRunIds, filters: {} }); }
catch { /* An unusable timeslot leaves only the client as origin. */ }
}
return originTokens(projectId, timeslot);
} }
private async validateBatchScope(projectId: string, requested: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchScope> { private async validateBatchScope(projectId: string, requested: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchScope> {
@ -508,7 +563,7 @@ export class RegulatoryMappingService {
counts.distinctRuleCount = representatives.size; counts.distinctRuleCount = representatives.size;
const scheduled: Array<Promise<'succeeded' | 'failed'>> = []; const scheduled: Array<Promise<'succeeded' | 'failed'>> = [];
for (const finding of representatives.values()) { for (const finding of representatives.values()) {
const claimed = await this.claim(finding, false); const claimed = await this.claim(finding, false, 'batch');
if (!claimed.claimed) { if (!claimed.claimed) {
if (claimed.run.status === 'succeeded') counts.reusedSucceededRuleCount += 1; if (claimed.run.status === 'succeeded') counts.reusedSucceededRuleCount += 1;
else counts.reusedActiveRuleCount += 1; else counts.reusedActiveRuleCount += 1;
@ -738,6 +793,15 @@ function isGpoRuleKey(ruleKey: string): boolean {
return ruleKey.startsWith('GPOs:') || ruleKey.startsWith('GPOs#'); return ruleKey.startsWith('GPOs:') || ruleKey.startsWith('GPOs#');
} }
/** Content-free origin tokens of a client and its timeslot; equal inputs give equal tokens. */
function originTokens(projectId: string, scope: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): { project: string; timeslot: string[] } {
return {
project: sha256(`origin-project:${projectId}`),
timeslot: [...scope.assessmentIds.map(id => sha256(`origin-assessment:${projectId}:${id}`)),
...scope.scanRunIds.map(id => sha256(`origin-scan-run:${projectId}:${id}`))].sort()
};
}
function sha256(value: string): string { function sha256(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex'); return createHash('sha256').update(value, 'utf8').digest('hex');
} }

View File

@ -127,6 +127,13 @@ export interface RegulatoryMappingRun {
promptVersion: string; promptVersion: string;
outputSchemaVersion: 1; outputSchemaVersion: 1;
model: string; model: string;
/** How the run was started: a single finding (`Map ISO controls`) or a batch. Absent on runs before 2.21.0. */
trigger?: 'single' | 'batch';
/**
* Where a single mapping was started, as SHA-256 tokens of the client and of each assessment and scan run of
* its timeslot. Runs are global and exported with every client, so no client or timeslot id is stored.
*/
origin?: { project: string; timeslot: string[] };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
startedAt?: string; startedAt?: string;
@ -190,6 +197,28 @@ export interface RegulatoryControlOverviewItem {
} }
/** Accepted controls of the deviations in one client timeslot; decisions stay global per scanner rule. */ /** Accepted controls of the deviations in one client timeslot; decisions stay global per scanner rule. */
/** Idempotency-key prefixes of batches the automatic mapping starts; every other batch was started manually. */
export const AUTO_MAPPING_BATCH_KEY_PREFIXES = ['auto:', 'auto-gpo:'] as const;
export const MAPPING_HISTORY_LIMIT = 50;
/**
* Content-free entry of the Client Info `Last mapping` window, listed globally: a batch of any client or a
* single finding mapping, which is global per rule.
*/
export interface RegulatoryMappingHistoryEntry {
id: string;
kind: 'batch' | 'single';
/** Where the mapping was started, relative to the shown timeslot; `unknown` for a single mapping without a recorded origin. */
scope: 'timeslot' | 'other-timeslot' | 'other-client' | 'unknown';
/** Automatic only for batches the auto-mapping watcher started. */
trigger: 'automatic' | 'manual';
status: RegulatoryMappingBatchStatus;
createdAt: string;
completedAt?: string;
/** `findings` only for a batch: the eligible findings of its own scope. */
counts: { findings?: number; sentToModel: number; reused: number; succeeded: number; failed: number };
}
export interface RegulatoryControlOverviewResponse { export interface RegulatoryControlOverviewResponse {
version: 1; version: 1;
projectId: string; projectId: string;
@ -197,6 +226,8 @@ export interface RegulatoryControlOverviewResponse {
/** Distinct findings that carry at least one accepted control. */ /** Distinct findings that carry at least one accepted control. */
findingCount: number; findingCount: number;
controls: RegulatoryControlOverviewItem[]; controls: RegulatoryControlOverviewItem[];
/** Newest first, at most MAPPING_HISTORY_LIMIT; only on the Client Info overview. */
mappingHistory?: RegulatoryMappingHistoryEntry[];
} }
export interface RegulatoryMappingResponse { export interface RegulatoryMappingResponse {
@ -224,6 +255,8 @@ export interface RegulatoryEarlierControl {
export interface RegulatoryMappingCreateRequest { export interface RegulatoryMappingCreateRequest {
resultProjectionId: string; resultProjectionId: string;
/** The timeslot the Finding Reader shows; the run keeps it only as hashes, for the mapping history scope. */
origin?: { assessmentIds: string[]; scanRunIds: string[] };
} }
export interface RegulatoryMappingDecisionCreateRequest { export interface RegulatoryMappingDecisionCreateRequest {

View File

@ -1,10 +1,10 @@
import { useEffect, useId, useState } from 'react'; import { useEffect, useId, useState, type ReactNode } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import type { BackendStatus } from '../../app/app-state'; import type { BackendStatus } from '../../app/app-state';
import { useEnginePreferences } from '../../app/engine-preferences'; import { useEnginePreferences } from '../../app/engine-preferences';
import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts'; import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts';
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton'; import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping'; import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } from './decision-revision'; import { useRegulatoryDecisionRevision } from './decision-revision';
import './compliance.css'; import './compliance.css';
@ -21,6 +21,8 @@ interface Props {
onOpenControl(controlId: string): void; onOpenControl(controlId: string): void;
/** Opens Work filtered to this ISO theme. */ /** Opens Work filtered to this ISO theme. */
onOpenTheme(theme: RegulatoryControlTheme): void; onOpenTheme(theme: RegulatoryControlTheme): void;
/** The Client Info tab bar, shown above this tab like on the AI Overview. */
navigation?: ReactNode;
} }
type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse }; type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse };
@ -45,26 +47,45 @@ function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] {
} }
/** /**
* Client Info `Compliance` row: one tile per framework in the environment tile geometry, the complete * Client Info `Compliance` tab: one tile per framework in the environment tile geometry, the complete
* control list in its Results window. ISO/IEC 27001/27002 is the first framework. * control list in its Results window, and when the timeslot was last mapped. ISO/IEC 27001/27002 is the first framework.
*/ */
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme }: Props) { export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, navigation }: Props) {
const available = useEnginePreferences().compliance; const available = useEnginePreferences().compliance;
const revision = useRegulatoryDecisionRevision(); const revision = useRegulatoryDecisionRevision();
const id = useId(); const id = useId();
const [state, setState] = useState<OverviewState>({ status: 'idle' });
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]); const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
const enabled = available && backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0); const enabled = available && backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
// Returning to the tab shows the last overview of this timeslot at once; the refresh below replaces it.
const [state, setState] = useState<OverviewState>(() => {
const cached = enabled ? overviewCache.get(scopeKey) : undefined;
return cached ? { status: 'ready', overview: cached } : { status: 'idle' };
});
const [poll, setPoll] = useState(0);
useEffect(() => { useEffect(() => {
if (!enabled || !projectId) { setState({ status: 'idle' }); return; } if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
let active = true; let active = true;
setState({ status: 'loading' }); const cached = overviewCache.get(scopeKey);
// A poll or a remembered overview keeps the shown values until the refreshed overview arrives.
if (cached) setState({ status: 'ready', overview: cached });
else if (poll === 0) setState({ status: 'loading' });
assessorApi.getRegulatoryControlOverview(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] }) assessorApi.getRegulatoryControlOverview(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] })
.then(overview => { if (active) setState({ status: 'ready', overview }); }) .then(overview => { if (active) { rememberOverview(scopeKey, overview); setState({ status: 'ready', overview }); } })
.catch(() => { if (active) setState({ status: 'error' }); }); .catch(() => { if (active && !cached && poll === 0) setState({ status: 'error' }); });
return () => { active = false; }; return () => { active = false; };
}, [enabled, revision, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps }, [enabled, revision, scopeKey, poll]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setPoll(0); }, [enabled, revision, scopeKey]);
const history = state.status === 'ready' ? state.overview.mappingHistory ?? [] : [];
const mappingActive = history.some(entry => entry.status === 'queued' || entry.status === 'running');
// While a mapping of this timeslot runs, its progress and newly accepted controls appear without a reload.
useEffect(() => {
if (!mappingActive) return;
const timer = setTimeout(() => setPoll(value => value + 1), MAPPING_POLL_MS);
return () => clearTimeout(timer);
}, [mappingActive, state]);
if (!available || state.status === 'idle') return null; if (!available || state.status === 'idle') return null;
const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : []; const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : [];
@ -118,17 +139,73 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
</div>; </div>;
}; };
return <section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}> return <div className="dashboard-page client-command-center client-compliance-page">
<h2 id={`${id}-title`}>Compliance</h2> {navigation}
{/* Same tile geometry as the environment row; later frameworks add their tiles here. */} <section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<div className="client-compliance-grid" aria-label="Compliance frameworks"> <header className="client-compliance-header">
<section className="client-env-group" data-framework="iso-27001-27002"> <h2 id={`${id}-title`}>Compliance</h2>
<header className="client-env-group-header"> {state.status === 'ready' && <LastMapping history={history}/>}
<h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{ISO_TILE_TITLE}</span></h3> </header>
{state.status === 'ready' && <EnvironmentResultsButton title={ISO_TILE_TITLE}>{results}</EnvironmentResultsButton>} {/* Same tile geometry as the environment row; later frameworks add their tiles here. */}
</header> <div className="client-compliance-grid" aria-label="Compliance frameworks">
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts} <section className="client-env-group" data-framework="iso-27001-27002">
</section> <header className="client-env-group-header">
</div> <h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{ISO_TILE_TITLE}</span></h3>
</section>; {state.status === 'ready' && <EnvironmentResultsButton title={ISO_TILE_TITLE}>{results}</EnvironmentResultsButton>}
</header>
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts}
</section>
</div>
</section>
</div>;
}
// Last overview per client and timeslot, so the tab does not start empty on every visit. Every visit,
// decision and running mapping still requests a fresh overview; the bound keeps a few recent timeslots.
const OVERVIEW_CACHE_LIMIT = 8;
const overviewCache = new Map<string, RegulatoryControlOverviewResponse>();
function rememberOverview(scopeKey: string, overview: RegulatoryControlOverviewResponse) {
overviewCache.delete(scopeKey);
overviewCache.set(scopeKey, overview);
while (overviewCache.size > OVERVIEW_CACHE_LIMIT) overviewCache.delete(overviewCache.keys().next().value!);
}
/** Test support. */
export function resetClientComplianceCacheForTests(): void { overviewCache.clear(); }
const MAPPING_POLL_MS = 5_000;
const triggerLabel = (entry: RegulatoryMappingHistoryEntry) =>
entry.kind === 'single' ? 'Single finding' : entry.trigger === 'automatic' ? 'Automatic' : 'Manual';
const SCOPE_LABEL = { timeslot: 'This timeslot', 'other-timeslot': 'Other timeslot', 'other-client': 'Other client', unknown: 'Unknown' } as const;
const STATUS_LABEL = { queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' } as const;
const dateTime = (value: string) => new Date(value).toLocaleString('en-GB',
{ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
/**
* The latest ISO mapping of any client and what started it; mappings are global per rule. The window lists
* the latest batches and single finding mappings with their scope and rule counts.
*/
function LastMapping({ history }: { history: RegulatoryMappingHistoryEntry[] }) {
const latest = history[0];
if (!latest) return <span className="client-compliance-last-mapping is-empty">No mapping yet</span>;
const label = `Last mapping: ${dateTime(latest.createdAt)} · ${triggerLabel(latest)}`
+ (latest.scope === 'timeslot' ? '' : ` · ${SCOPE_LABEL[latest.scope]}`)
+ (latest.status === 'completed' ? '' : ` · ${STATUS_LABEL[latest.status]}`);
return <EnvironmentResultsButton title="ISO mapping history" trigger={label} triggerClassName="btn client-compliance-last-mapping">
<div className="client-env-evidence-scroll">
<table className="client-information-table client-compliance-history">
<thead><tr><th>Started</th><th>Trigger</th><th>Scope</th><th>Status</th><th>Findings</th><th>Sent to model</th><th>Reused</th><th>Succeeded</th><th>Failed</th></tr></thead>
<tbody>{history.map(entry => <tr key={entry.id}>
<td>{dateTime(entry.createdAt)}</td>
<td>{triggerLabel(entry)}</td>
<td>{SCOPE_LABEL[entry.scope]}</td>
<td>{STATUS_LABEL[entry.status]}</td>
<td>{entry.counts.findings === undefined ? '—' : number(entry.counts.findings)}</td>
<td>{number(entry.counts.sentToModel)}</td>
<td>{number(entry.counts.reused)}</td>
<td>{number(entry.counts.succeeded)}</td>
<td>{number(entry.counts.failed)}</td>
</tr>)}</tbody>
</table>
</div>
</EnvironmentResultsButton>;
} }

View File

@ -9,9 +9,11 @@
.finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; } .finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; }
.finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; } .finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
/* Client Info `Compliance` row; its tiles use the environment tile geometry. */ /* Client Info `Compliance` tab; its tiles use the environment tile geometry. */
.client-compliance { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; } .client-compliance-page { align-content: start; }
.client-compliance > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } .client-compliance { margin-block: 0 var(--space-8); color: var(--text-primary); min-width: 0; }
.client-compliance-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
.client-compliance-header > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); } .iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); }
/* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */ /* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */
.client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; } .client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; }
@ -58,3 +60,9 @@ button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: un
/* Data Hub ISO tile: switch for automatic mapping of newly uploaded scans. */ /* Data Hub ISO tile: switch for automatic mapping of newly uploaded scans. */
.iso-auto-mapping-toggle { display: flex; align-items: center; gap: var(--space-2); color: var(--text-primary); font-size: var(--type-table-body-size); } .iso-auto-mapping-toggle { display: flex; align-items: center; gap: var(--space-2); color: var(--text-primary); font-size: var(--type-table-body-size); }
/* `Last mapping` button and its empty state beside the tab heading. */
.client-compliance-last-mapping { font-variant-numeric: tabular-nums; }
.client-compliance-last-mapping.is-empty { color: var(--text-secondary); font-size: var(--type-table-body-size); }
/* Mapping history is a data table, not field/value pairs: every cell keeps the 14px table role. */
.client-information-table.client-compliance-history td:nth-child(2) { font-size: var(--type-table-body-size); }

View File

@ -3,6 +3,7 @@ import type { BackendStatus } from '../../app/app-state';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import { useEnginePreferences } from '../../app/engine-preferences'; import { useEnginePreferences } from '../../app/engine-preferences';
import { useOptionalApp } from '../../app/AppProvider';
import { mappingRunKey, subscribeRegulatoryMappingRuns, trackRegulatoryMappingRun } from './mapping-run-tracker'; import { mappingRunKey, subscribeRegulatoryMappingRuns, trackRegulatoryMappingRun } from './mapping-run-tracker';
import { markRegulatoryDecisionSaved } from './decision-revision'; import { markRegulatoryDecisionSaved } from './decision-revision';
import { import {
@ -29,6 +30,10 @@ export function useRegulatoryMapping(
) { ) {
// A switched-off Compliance Engine shows no control, opens nothing and sends no request. // A switched-off Compliance Engine shows no control, opens nothing and sends no request.
const available = useEnginePreferences().compliance; const available = useEnginePreferences().compliance;
// The shown timeslot is where a single mapping comes from; the mapping history names it relative to its view.
const app = useOptionalApp();
const origin = useRef({ assessmentIds: [] as string[], scanRunIds: [] as string[] });
origin.current = { assessmentIds: [...(app?.activeAssessmentIds ?? [])], scanRunIds: [...(app?.activeScanRunIds ?? [])] };
const [requestedOpen, setOpen] = useState(false); const [requestedOpen, setOpen] = useState(false);
const open = requestedOpen && available; const open = requestedOpen && available;
// Re-enabling the engine starts with the window closed again. // Re-enabling the engine starts with the window closed again.
@ -77,7 +82,7 @@ export function useRegulatoryMapping(
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return; if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
setLoading(true); setLoading(true);
try { try {
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId }); const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId, origin: origin.current });
trackRegulatoryMappingRun(projectId, resultProjectionId, next); trackRegulatoryMappingRun(projectId, resultProjectionId, next);
if (activeScope.current !== requestScope) return; if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next }); setStoredResponse({ scope: requestScope, value: next });

View File

@ -1147,18 +1147,22 @@ export function dashboardAnalysis(
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const app = useApp(); const app = useApp();
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app; const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender' | 'ai'>('overview'); const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender' | 'compliance' | 'ai'>('overview');
const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]); const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]);
const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID; const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID;
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds); const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []); const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []);
const showAiOverview = aiOverview.available && Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run); 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(() => { useEffect(() => {
if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender'); if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender');
else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('overview'); else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('overview');
else if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView(pureDefender ? 'defender' : 'overview'); else if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView(pureDefender ? 'defender' : 'overview');
}, [clientInfoView, hasDefender, pureDefender, showAiOverview]); else if (clientInfoView === 'compliance' && !showCompliance) setClientInfoView(pureDefender ? 'defender' : 'overview');
}, [clientInfoView, hasDefender, pureDefender, showAiOverview, showCompliance]);
const gpoInventory = useGpoInventory(app.targetProjectId); const gpoInventory = useGpoInventory(app.targetProjectId);
const sourceFilter = app.facetFilters['provenance:source-scan']; const sourceFilter = app.facetFilters['provenance:source-scan'];
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter); const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
@ -1234,9 +1238,19 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
if (!dataset || !activeSheet) return app.targetProjectId if (!dataset || !activeSheet) return app.targetProjectId
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div> ? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
: <EmptyWorkspace />; : <EmptyWorkspace />;
const clientInfoViews: Array<readonly ['overview' | 'defender' | 'ai', string]> = [ 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]), ...(pureDefender ? [] : [['overview', 'Overview'] as const]),
...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []), ...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []),
...(showCompliance ? [['compliance', 'Compliance'] as const] : []),
...(showAiOverview ? [['ai', 'AI Overview'] as const] : []) ...(showAiOverview ? [['ai', 'AI Overview'] as const] : [])
]; ];
const clientInfoTabs = clientInfoViews.length > 1 ? <nav className="client-info-tabs" aria-label="Client Info views"> const clientInfoTabs = clientInfoViews.length > 1 ? <nav className="client-info-tabs" aria-label="Client Info views">
@ -1244,6 +1258,24 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
<button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)} <button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)}
</nav> : undefined; </nav> : undefined;
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />; 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)) { if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews} return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds} scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}
@ -1294,15 +1326,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0); const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
const overallTone = dashboardScoreTone(overallScore); const overallTone = dashboardScoreTone(overallScore);
const jump = (apply: () => void, preserveSource = true) => {
resetFilters();
if (preserveSource && sourceFilter && sourceFacet) setFacetFilter(sourceFacet.id, sourceFilter);
app.setActionOnly(false);
setStatusFilter('deviation');
apply();
setView('columns');
onOpenWork();
};
const sourceOverviewContent = (dismissResults: () => void) => orderedSourceScores.length ? <ul>{orderedSourceScores.map(source => const sourceOverviewContent = (dismissResults: () => void) => orderedSourceScores.length ? <ul>{orderedSourceScores.map(source =>
<DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics} <DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics}
onClick={sourceFacet && source.tool.sourceValues.length ? () => { dismissResults(); jump(() => { onClick={sourceFacet && source.tool.sourceValues.length ? () => { dismissResults(); jump(() => {
@ -1429,25 +1452,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] }); setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
}, false)} /> }, false)} />
{/* Compliance Engine: `Compliance` row with one tile per framework; its Results window opens a control or theme in Work. */}
<ClientIsoControls 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)} />
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />} {scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<div className="client-environment-grid" aria-label="Environment overview"> <div className="client-environment-grid" aria-label="Environment overview">

View File

@ -8,14 +8,14 @@ import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { requestActivity } from '../../src/api/request-activity'; import { requestActivity } from '../../src/api/request-activity';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping'; import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
import { ClientIsoControls } from '../../src/compliance/ui/ClientIsoControls'; import { ClientIsoControls, resetClientComplianceCacheForTests } from '../../src/compliance/ui/ClientIsoControls';
import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision'; import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision';
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup'; import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
import { makeSheet } from '../../src/importers/sheet-factory'; import { makeSheet } from '../../src/importers/sheet-factory';
// Client Info `Compliance` row: one ISO tile in the environment tile geometry with totals and the two themes // Client Info `Compliance` tab: one ISO tile in the environment tile geometry with totals and all four themes;
// with the most findings; its Results window lists every control by theme and opens Work from a control or // its Results window lists every control by theme and opens Work from a control or a theme. `Last mapping`
// a theme. Switched off, nothing renders or loads. // names the newest mapping batch of the timeslot and its trigger. Switched off, nothing renders or loads.
const overview: RegulatoryControlOverviewResponse = { const overview: RegulatoryControlOverviewResponse = {
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3, version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
controls: [ controls: [
@ -45,6 +45,7 @@ beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear(); localStorage.clear();
resetEnginePreferencesForTests(); resetEnginePreferencesForTests();
resetClientComplianceCacheForTests();
onOpenControl.mockReset(); onOpenControl.mockReset();
onOpenTheme.mockReset(); onOpenTheme.mockReset();
host = document.createElement('div'); host = document.createElement('div');
@ -70,7 +71,7 @@ describe('Client Info ISO controls', () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview); const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render(); await render();
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] }); expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
expect(host.querySelector('.client-compliance > h2')?.textContent).toBe('Compliance'); expect(host.querySelector('.client-compliance-header > h2')?.textContent).toBe('Compliance');
// Same tile geometry as the environment row. // Same tile geometry as the environment row.
expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull(); expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull();
expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002'); expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002');
@ -146,12 +147,17 @@ describe('Client Info ISO controls', () => {
expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' }); expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' });
}); });
it('places the section below the Context heatmap and opens Work through the ISO control and theme filters', () => { it('is its own Client Info tab, not part of the Overview, and opens Work through the ISO control and theme filters', () => {
const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8'); const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8');
expect(source.indexOf('<ClientIsoControls')).toBeGreaterThan(source.indexOf('<ClientContextHeatmap')); expect(source).toContain("...(showCompliance ? [['compliance', 'Compliance'] as const] : [])");
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('client-environment-grid')); expect(source.indexOf("['compliance', 'Compliance']")).toBeLessThan(source.indexOf("['ai', 'AI Overview']"));
const drilldown = source.slice(source.indexOf('<ClientIsoControls'), source.indexOf('client-environment-grid')); const tab = source.slice(source.indexOf("if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls"));
expect(drilldown).toContain('setStatusFilter(\x27deviation\x27)'); expect(tab.startsWith("if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls navigation={clientInfoTabs}")).toBe(true);
// The Overview page no longer renders the row.
expect(source.indexOf('<ClientIsoControls')).toBe(source.lastIndexOf('<ClientIsoControls'));
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('<ClientContextHeatmap'));
const drilldown = tab.slice(0, tab.indexOf('if (hasDefender'));
expect(drilldown).toContain("setStatusFilter('deviation')");
expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })'); expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })'); expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })');
expect(drilldown).not.toContain('setPathFindingIds'); expect(drilldown).not.toContain('setPathFindingIds');
@ -161,4 +167,72 @@ describe('Client Info ISO controls', () => {
const dataset = { fileName: 'Dummy', sheets: [{ ...sheet, analysis: { ...sheet.analysis, records: [record] } }] } as never; const dataset = { fileName: 'Dummy', sheets: [{ ...sheet, analysis: { ...sheet.analysis, records: [record] } }] } as never;
expect(workRecordIdsForResult(dataset, overview.controls[0].findings[0])).toEqual([record.id]); expect(workRecordIdsForResult(dataset, overview.controls[0].findings[0])).toEqual([record.id]);
}); });
const counts = { findings: 7, sentToModel: 2, reused: 3, succeeded: 2, failed: 0 };
const lastMapping = () => host.querySelector<HTMLElement>('.client-compliance-last-mapping');
it('shows the latest mapping of any client and lists batches and single finding mappings with where they came from', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, mappingHistory: [
{ id: 'dummy-batch-2', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'completed', createdAt: '2026-10-08T09:12:00Z', completedAt: '2026-10-08T09:14:00Z', counts },
{ id: 'dummy-single', kind: 'single', scope: 'other-timeslot', trigger: 'manual', status: 'completed', createdAt: '2026-10-08T08:00:00Z',
counts: { sentToModel: 1, reused: 0, succeeded: 1, failed: 0 } },
{ id: 'dummy-batch-1', kind: 'batch', scope: 'other-client', trigger: 'manual', status: 'failed', createdAt: '2026-10-07T15:00:00Z', counts: { ...counts, failed: 2, succeeded: 0 } }] });
await render();
const expectedTime = new Date('2026-10-08T09:12:00Z').toLocaleString('en-GB',
{ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
expect(lastMapping()?.tagName).toBe('BUTTON');
expect(lastMapping()?.textContent).toBe(`Last mapping: ${expectedTime} · Automatic`);
// The framework Results button stays on the tile, unchanged.
expect(resultsButton()?.textContent).toBe('Results');
await act(async () => lastMapping()!.click());
const rows = [...document.querySelectorAll('.client-information-table tbody tr')].map(row => [...row.children].slice(1).map(cell => cell.textContent));
expect(rows).toEqual([['Automatic', 'This timeslot', 'Completed', '7', '2', '3', '2', '0'],
['Single finding', 'Other timeslot', 'Completed', '—', '1', '0', '1', '0'],
['Manual', 'Other client', 'Failed', '7', '2', '3', '0', '2']]);
expect(document.querySelector('.client-compliance-history')).not.toBeNull();
});
it('names the scope on the button when the latest mapping is not this timeslot', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, mappingHistory: [
{ id: 'dummy-single', kind: 'single', scope: 'other-client', trigger: 'manual', status: 'running', createdAt: '2026-10-08T08:00:00Z',
counts: { sentToModel: 1, reused: 0, succeeded: 0, failed: 0 } }] });
await render();
expect(lastMapping()?.textContent).toMatch(/· Single finding · Other client · Running$/);
});
it('says when the timeslot was never mapped, and refreshes while a mapping runs', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce({ ...overview, mappingHistory: [] });
await render();
expect(lastMapping()?.tagName).toBe('SPAN');
expect(lastMapping()?.textContent).toBe('No mapping yet');
vi.useFakeTimers();
try {
request.mockResolvedValueOnce({ ...overview, mappingHistory: [{ id: 'dummy-batch', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'running', createdAt: '2026-10-08T09:12:00Z', counts }] })
.mockResolvedValue({ ...overview, mappingHistory: [{ id: 'dummy-batch', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'completed', createdAt: '2026-10-08T09:12:00Z', counts }] });
await act(async () => { markRegulatoryDecisionSaved(); });
expect(lastMapping()?.textContent).toMatch(/· Automatic · Running$/);
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(lastMapping()?.textContent).toMatch(/· Automatic$/);
// Values stay visible during the refresh; nothing falls back to an em dash.
expect(facts()[0]).toEqual(['Controls', '3']);
} finally { vi.useRealTimers(); }
});
it('shows the last overview at once when returning to the tab, and refreshes it in the background', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce(overview);
await render();
expect(facts()[0]).toEqual(['Controls', '3']);
// Switching to another Client Info tab unmounts this one.
await act(async () => root.render(null));
let refresh: (value: RegulatoryControlOverviewResponse) => void = () => undefined;
request.mockReturnValueOnce(new Promise(resolve => { refresh = resolve; }));
await render();
expect(request).toHaveBeenCalledTimes(2);
expect(facts()[0]).toEqual(['Controls', '3']);
expect(resultsButton()).not.toBeNull();
await act(async () => refresh({ ...overview, controls: overview.controls.slice(0, 1), findingCount: 2 }));
expect(facts()[0]).toEqual(['Controls', '1']);
});
}); });

View File

@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { useOptionalApp } from '../../src/app/AppProvider';
import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping';
import { useRegulatoryMapping } from '../../src/compliance/ui/use-regulatory-mapping';
vi.mock('../../src/app/AppProvider', () => ({ useOptionalApp: vi.fn() }));
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const response: RegulatoryMappingResponse = {
version: 1, projectId: 'dummy-project', resultProjectionId: 'scanner-finding:dummy', generationEnabled: true, state: 'empty', decisions: []
};
function Probe(): ReactNode {
const controller = useRegulatoryMapping('dummy-project', 'scanner-finding:dummy', 'connected');
return <button type="button" onClick={() => void controller.start()}>Map ISO controls</button>;
}
// The mapping history names where a single mapping came from, so `Map ISO controls` sends the shown timeslot.
it('sends the timeslot the Finding Reader shows with a single mapping', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.mocked(useOptionalApp).mockReturnValue({ activeAssessmentIds: ['dummy-assessment'], activeScanRunIds: ['dummy-scan-run'] } as never);
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue(response);
const generate = vi.spyOn(AssessorApiClient.prototype, 'generateRegulatoryMappings').mockResolvedValue(response);
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<Probe/>));
await act(async () => container.querySelector('button')!.click());
expect(generate).toHaveBeenCalledWith('dummy-project', {
resultProjectionId: 'scanner-finding:dummy', origin: { assessmentIds: ['dummy-assessment'], scanRunIds: ['dummy-scan-run'] }
});
} finally {
await act(async () => root.unmount());
container.remove();
}
});

View File

@ -217,6 +217,62 @@ describe('regulatory mapping service', () => {
await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project'); await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project');
}); });
it('reports every batch and single finding mapping globally, marked by where it came from, on the Client Info overview only', async () => {
const repository = mockRepository([finding(),
finding({ assessmentId: 'assessment-3', scanRunId: 'run-3', resultProjectionId: 'scanner-finding:earlier', sourceResultId: 'earlier',
sourceRuleId: 'C-Earlier', title: 'Earlier synthetic check' }),
finding({ projectId: 'project-2', assessmentId: 'assessment-2', scanRunId: 'run-2',
resultProjectionId: 'scanner-finding:other', sourceResultId: 'other', sourceRuleId: 'B-Other', title: 'Other synthetic check' })]);
const svc = service(repository, vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1',
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } })));
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
const run = async (action: Promise<unknown>) => { await action; await svc.close(); };
await run(svc.startBatch('project-1', { ...scope, filters: {}, idempotencyKey: 'regulatory-batch-dummy-manual' }));
await run(svc.startBatch('project-1', { ...scope, filters: { deviation: true }, idempotencyKey: 'auto:run-1' }));
await run(svc.startBatch('project-1', { assessmentIds: ['assessment-3'], scanRunIds: ['run-3'], filters: {}, idempotencyKey: 'regulatory-batch-dummy-earlier' }));
await run(svc.startBatch('project-2', { assessmentIds: ['assessment-2'], scanRunIds: ['run-2'], filters: {}, idempotencyKey: 'regulatory-batch-dummy-other' }));
// Single mappings from the Finding Reader carry the timeslot they were started in.
await run(svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', origin: scope }));
await run(svc.start('project-1', { resultProjectionId: 'scanner-finding:earlier', origin: { assessmentIds: ['assessment-3'], scanRunIds: ['run-3'] } }));
await run(svc.start('project-2', { resultProjectionId: 'scanner-finding:other', origin: { assessmentIds: ['assessment-2'], scanRunIds: ['run-2'] } }));
const overview = await svc.controlOverview('project-1', scope);
expect(overview.mappingHistory?.map(entry => [entry.kind, entry.scope, entry.trigger]).sort()).toEqual([
['batch', 'other-client', 'manual'], ['batch', 'other-timeslot', 'manual'], ['batch', 'timeslot', 'automatic'], ['batch', 'timeslot', 'manual'],
['single', 'other-client', 'manual'], ['single', 'other-timeslot', 'manual'], ['single', 'timeslot', 'manual']]);
// The automatic batch reused the rule the first one mapped.
const automatic = overview.mappingHistory!.find(entry => entry.trigger === 'automatic')!;
expect(automatic.counts).toEqual({ findings: 1, sentToModel: 0, reused: 1, succeeded: 0, failed: 0 });
const single = overview.mappingHistory!.find(entry => entry.kind === 'single' && entry.scope === 'timeslot')!;
expect(single).toMatchObject({ status: 'completed', counts: { sentToModel: 1, reused: 0, succeeded: 1, failed: 0 } });
expect(single.counts.findings).toBeUndefined();
// Content-free: no client, rule, scope id, filter or key leaves the engine with an entry.
expect(Object.keys(single).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'id', 'kind', 'scope', 'status', 'trigger']);
expect(Object.keys(automatic).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'id', 'kind', 'scope', 'status', 'trigger']);
// The stored origin is hashed: runs are global and exported with every client, so no id is kept.
const stored = JSON.stringify(await repository.listRegulatoryMappingRuns());
for (const id of ['project-1', 'project-2', 'assessment-1', 'assessment-2', 'assessment-3', 'run-1', 'run-2', 'run-3']) {
expect(stored).not.toContain(`"${id}"`);
}
expect((await svc.controlAssignments('project-1', scope)).mappingHistory).toBeUndefined();
});
it('marks a single mapping without a usable origin as unknown, or as the client only', async () => {
const repository = mockRepository([finding()]);
const svc = service(repository, vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1',
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } })));
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
// A foreign timeslot is not recorded; the client still is.
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', origin: { assessmentIds: ['foreign-assessment'], scanRunIds: [] } });
await svc.close();
expect((await svc.controlOverview('project-1', scope)).mappingHistory?.map(entry => entry.scope)).toEqual(['other-timeslot']);
// A run without origin, as recorded before this change, is unknown.
const [stored] = await repository.listRegulatoryMappingRuns();
const { origin: _origin, ...withoutOrigin } = stored;
await repository.saveRegulatoryMappingRun(withoutOrigin);
expect((await svc.controlOverview('project-1', scope)).mappingHistory?.map(entry => entry.scope)).toEqual(['unknown']);
});
it('counts a GPO setting once and gives Work its setting ID although the Result DB withholds it', async () => { it('counts a GPO setting once and gives Work its setting ID although the Result DB withholds it', async () => {
// Internal comparisons carry the setting ID; the Result DB rows of the same comparisons do not. // Internal comparisons carry the setting ID; the Result DB rows of the same comparisons do not.
const comparison = (id: string, settingId: string, title: string, policyScope: 'computer' | 'user') => finding({ const comparison = (id: string, settingId: string, title: string, policyScope: 'computer' | 'user') => finding({

View File

@ -7,12 +7,15 @@ import { SheetAndSearchControls } from '../../src/features/search-filter/SearchF
import { makeSheet } from '../../src/importers/sheet-factory'; import { makeSheet } from '../../src/importers/sheet-factory';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
const SHEET_COUNT = 300;
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
it('mounts only the selected sheet until focus, then offers every sheet and preserves the selection', async () => { it('mounts only the selected sheet until focus, then offers every sheet and preserves the selection', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const template = makeSheet('Synthetic', [['Title'], ['One']], '', 'synthetic.csv'); const template = makeSheet('Synthetic', [['Title'], ['One']], '', 'synthetic.csv');
const sheets = Array.from({ length: 2537 }, (_, index) => ({ ...template, id: `sheet-${index}`, name: `Synthetic sheet ${index}` })); // The selector defers its options at every size, so a few hundred sheets prove the same behaviour as
// thousands; mounting thousands of jsdom options made this test time out under a parallel full run.
const sheets = Array.from({ length: SHEET_COUNT }, (_, index) => ({ ...template, id: `sheet-${index}`, name: `Synthetic sheet ${index}` }));
const state = { dataset: { sheets }, activeSheetId: sheets[4].id, status: '', setActiveSheet: vi.fn() } as unknown as ReturnType<typeof useApp>; const state = { dataset: { sheets }, activeSheetId: sheets[4].id, status: '', setActiveSheet: vi.fn() } as unknown as ReturnType<typeof useApp>;
vi.mocked(useApp).mockReturnValue(state); vi.mocked(useApp).mockReturnValue(state);
const container = document.createElement('div'); const container = document.createElement('div');
@ -24,14 +27,14 @@ it('mounts only the selected sheet until focus, then offers every sheet and pres
expect(select.options).toHaveLength(1); expect(select.options).toHaveLength(1);
expect(select.value).toBe(sheets[4].id); expect(select.value).toBe(sheets[4].id);
await act(async () => select.focus()); await act(async () => select.focus());
expect(select.options).toHaveLength(2537); expect(select.options).toHaveLength(SHEET_COUNT);
await act(async () => { select.value = sheets[2500].id; select.dispatchEvent(new Event('change', { bubbles: true })); }); await act(async () => { select.value = sheets[250].id; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(state.setActiveSheet).toHaveBeenCalledWith(sheets[2500].id); expect(state.setActiveSheet).toHaveBeenCalledWith(sheets[250].id);
state.activeSheetId = sheets[2500].id; state.activeSheetId = sheets[250].id;
await act(async () => { select.blur(); root.render(<SheetAndSearchControls/>); }); await act(async () => { select.blur(); root.render(<SheetAndSearchControls/>); });
expect(select.options).toHaveLength(1); expect(select.options).toHaveLength(1);
expect(select.value).toBe(sheets[2500].id); expect(select.value).toBe(sheets[250].id);
await act(async () => select.dispatchEvent(new Event('pointerdown', { bubbles: true }))); await act(async () => select.dispatchEvent(new Event('pointerdown', { bubbles: true })));
expect(select.options).toHaveLength(2537); expect(select.options).toHaveLength(SHEET_COUNT);
} finally { await act(async () => root.unmount()); container.remove(); } } finally { await act(async () => root.unmount()); container.remove(); }
}); });