162 lines
8.3 KiB
TypeScript
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>;
|
|
}
|