Assessor_2.0/src/compliance/ui/RegulatoryMappingBatchDialo...

162 lines
8.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { useEnginePreferences } from '../../app/engine-preferences';
import { useBackendWorkActivity } from './use-backend-work-activity';
import { statusPollApi } from './status-poll-client';
import { Dialog } from '../../components/Dialog/Dialog';
import type { RegulatoryFramework, RegulatoryMappingBatchResponse, RegulatoryMappingBatchScope } from '../regulatory-mapping';
const assessorApi = new AssessorApiClient();
export interface RegulatoryMappingBatchController {
/** False while the Compliance Engine is switched off. */
available: boolean;
response?: RegulatoryMappingBatchResponse;
loading: boolean;
starting: boolean;
error?: string;
start(): Promise<void>;
}
/** The ISO batch, with the direct BSI batch of the same filters beside it (Phase 13F, BSI stage 2). */
export function useRegulatoryMappingBatch(requestedProjectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined): RegulatoryMappingBatchController & { bsi: RegulatoryMappingBatchController } {
const iso = useFrameworkBatch(requestedProjectId, scope, 'iso27001');
const bsi = useFrameworkBatch(requestedProjectId, scope, 'bsi-kernel');
return { ...iso, bsi };
}
function useFrameworkBatch(requestedProjectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined, framework: RegulatoryFramework): RegulatoryMappingBatchController {
// A switched-off Compliance Engine requests no batch status and offers no batch action.
const available = useEnginePreferences().compliance;
const projectId = available ? requestedProjectId : undefined;
const [response, setResponse] = useState<RegulatoryMappingBatchResponse>();
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string>();
const scopeKey = JSON.stringify(scope ?? null);
const requestKey = `${projectId ?? ''}\u0000${scopeKey}\u0000${framework}`;
const activeRequestKey = useRef(requestKey);
activeRequestKey.current = requestKey;
const refresh = useCallback(async (poll = false) => {
if (!projectId || !scope || (!scope.assessmentIds.length && !scope.scanRunIds.length)) {
setResponse(undefined);
return;
}
try {
const next = await (poll ? statusPollApi : assessorApi).getRegulatoryMappingBatch(projectId, scope, framework);
if (activeRequestKey.current !== requestKey) return;
setResponse(next);
setError(undefined);
} catch (reason) {
if (activeRequestKey.current !== requestKey) return;
setError(reason instanceof Error ? reason.message : 'Compliance mapping batch status could not be loaded.');
}
}, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
let active = true;
setLoading(true);
setStarting(false);
setError(undefined);
setResponse(undefined);
void refresh().finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [refresh, scopeKey]);
useEffect(() => {
if (!response || !['queued', 'running'].includes(response.state)) return;
const timer = window.setTimeout(() => void refresh(true), response.pollAfterMs ?? 1_500);
return () => window.clearTimeout(timer);
}, [refresh, response]);
const start = useCallback(async () => {
if (!projectId || !scope) return;
setStarting(true);
setError(undefined);
try {
const next = await assessorApi.startRegulatoryMappingBatch(projectId, {
...scope,
idempotencyKey: `regulatory-batch-${crypto.randomUUID()}`,
...(framework === 'iso27001' ? {} : { framework })
});
if (activeRequestKey.current !== requestKey) return;
setResponse(next);
} catch (reason) {
if (activeRequestKey.current !== requestKey) return;
setError(reason instanceof Error ? reason.message : `${framework === 'bsi-kernel' ? 'BSI' : 'ISO'} mapping batch could not be started.`);
} finally {
if (activeRequestKey.current === requestKey) setStarting(false);
}
}, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
useBackendWorkActivity(framework === 'bsi-kernel' ? 'Mapping all BSI requirements' : 'Mapping all ISO controls', !response || response.state === 'empty' ? 'idle'
: response.state === 'completed' ? 'finished' : response.state);
return { available, response, loading, starting, error, start };
}
export function RegulatoryMappingBatchDialog({ controller, filteredResultCount, onClose }: {
controller: RegulatoryMappingBatchController & { bsi?: RegulatoryMappingBatchController };
filteredResultCount: number;
onClose(): void;
}) {
return <Dialog title="Compliance mapping" onClose={onClose}>
<div className="regulatory-batch-dialog">
<p>Generate ISO control proposals for all <strong>{filteredResultCount.toLocaleString('en-US')}</strong> results matching the current Result DB filters.</p>
<p>Pagination, “Load more,” and loaded-row limits do not restrict this batch. Proposals remain subject to manual Accept/Reject review.</p>
<BatchSection controller={controller} filteredResultCount={filteredResultCount}/>
{/* User decision 2026-10-09: the direct BSI mapping of the same filters, compared with ISO in the Client Info Compliance tab. */}
{controller.bsi && <>
<h3 className="regulatory-batch-heading">BSI Grundschutz++ requirements</h3>
<p>Map the same results directly to BSI Kernel requirements. The Compliance tab compares them with the BSI requirements derived from the accepted ISO controls.</p>
<BatchSection controller={controller.bsi} filteredResultCount={filteredResultCount} startLabel="Start BSI mapping batch"/>
</>}
<div className="dialog-actions">
<button className="btn" type="button" onClick={onClose}>Close</button>
</div>
</div>
</Dialog>;
}
function BatchSection({ controller, filteredResultCount, startLabel = 'Start mapping batch' }: {
controller: RegulatoryMappingBatchController;
filteredResultCount: number;
startLabel?: string;
}) {
const { response, loading, starting, error, start } = controller;
const run = response?.run;
const active = response && ['queued', 'running'].includes(response.state);
const counts = run?.counts;
const actionLabel = run ? 'Run again for current filters' : startLabel;
return <>
{loading && !response && <p role="status">Loading batch status…</p>}
{error && <p className="regulatory-mapping-error" role="alert">{error}</p>}
{response && !response.generationEnabled && <p role="status">{response.disabledReason ?? 'Regulatory mapping is unavailable.'}</p>}
{run && <>
<p className="regulatory-batch-state" role="status">Status: <strong>{run.status === 'completed' && run.counts.failedRuleCount ? 'completed with failures' : run.status}</strong></p>
<dl className="regulatory-batch-counts">
<BatchCount label="Filtered results" value={counts!.filteredResultCount}/>
<BatchCount label="Excluded context rows" value={counts!.excludedNonScannerCount}/>
<BatchCount label="Eligible findings" value={counts!.eligibleFindingCount}/>
<BatchCount label="Unsupported findings" value={counts!.unsupportedFindingCount}/>
<BatchCount label="Distinct rules" value={counts!.distinctRuleCount}/>
<BatchCount label="Reused mappings" value={counts!.reusedSucceededRuleCount + counts!.reusedActiveRuleCount}/>
<BatchCount label="Completed" value={counts!.succeededRuleCount}/>
<BatchCount label="Failed" value={counts!.failedRuleCount}/>
</dl>
{run.errorDetail && <p className="regulatory-mapping-error-detail">{run.errorDetail}</p>}
</>}
{active && <p role="status">Mapping is running on the server. You can close this dialog and return later.</p>}
{response?.generationEnabled && <div className="dialog-actions">
<button className="btn primary" type="button"
disabled={Boolean(active) || starting || filteredResultCount === 0}
onClick={() => void start()}>{starting ? 'Starting…' : actionLabel}</button>
</div>}
</>;
}
function BatchCount({ label, value }: { label: string; value: number }) {
return <div><dt>{label}</dt><dd>{value.toLocaleString('en-US')}</dd></div>;
}