125 lines
6.0 KiB
TypeScript
125 lines
6.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
|
import { Dialog } from '../../components/Dialog/Dialog';
|
|
import type { RegulatoryMappingBatchResponse, RegulatoryMappingBatchScope } from '../../domain/regulatory-mapping';
|
|
|
|
const assessorApi = new AssessorApiClient();
|
|
|
|
export interface RegulatoryMappingBatchController {
|
|
response?: RegulatoryMappingBatchResponse;
|
|
loading: boolean;
|
|
starting: boolean;
|
|
error?: string;
|
|
start(): Promise<void>;
|
|
}
|
|
|
|
export function useRegulatoryMappingBatch(projectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined): RegulatoryMappingBatchController {
|
|
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}`;
|
|
const activeRequestKey = useRef(requestKey);
|
|
activeRequestKey.current = requestKey;
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (!projectId || !scope || (!scope.assessmentIds.length && !scope.scanRunIds.length)) {
|
|
setResponse(undefined);
|
|
return;
|
|
}
|
|
try {
|
|
const next = await assessorApi.getRegulatoryMappingBatch(projectId, scope);
|
|
if (activeRequestKey.current !== requestKey) return;
|
|
setResponse(next);
|
|
setError(undefined);
|
|
} catch (reason) {
|
|
if (activeRequestKey.current !== requestKey) return;
|
|
setError(reason instanceof Error ? reason.message : 'ISO 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(), 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()}`
|
|
});
|
|
if (activeRequestKey.current !== requestKey) return;
|
|
setResponse(next);
|
|
} catch (reason) {
|
|
if (activeRequestKey.current !== requestKey) return;
|
|
setError(reason instanceof Error ? reason.message : 'ISO mapping batch could not be started.');
|
|
} finally {
|
|
if (activeRequestKey.current === requestKey) setStarting(false);
|
|
}
|
|
}, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return { response, loading, starting, error, start };
|
|
}
|
|
|
|
export function RegulatoryMappingBatchDialog({ controller, filteredResultCount, onClose }: {
|
|
controller: RegulatoryMappingBatchController;
|
|
filteredResultCount: number;
|
|
onClose(): void;
|
|
}) {
|
|
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' : 'Start mapping batch';
|
|
return <Dialog title="ISO mapping batch" 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>
|
|
{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>}
|
|
<div className="dialog-actions">
|
|
<button className="btn" type="button" onClick={onClose}>Close</button>
|
|
{response?.generationEnabled && <button className="btn primary" type="button"
|
|
disabled={Boolean(active) || starting || filteredResultCount === 0}
|
|
onClick={() => void start()}>{starting ? 'Starting…' : actionLabel}</button>}
|
|
</div>
|
|
</div>
|
|
</Dialog>;
|
|
}
|
|
|
|
function BatchCount({ label, value }: { label: string; value: number }) {
|
|
return <div><dt>{label}</dt><dd>{value.toLocaleString('en-US')}</dd></div>;
|
|
}
|