Assessor_2.0/src/features/results-browser/RegulatoryMappingBatchDialo...

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>;
}