352 lines
22 KiB
TypeScript
352 lines
22 KiB
TypeScript
import { beginActivity, showActivityFrame } from '../../platform/activity-store';
|
||
import { buildUnifiedResultCsv, unifiedResultCsvFileName } from '../../exports/unified-result-csv';
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||
import { resultSourceToolLabel, type UnifiedResultFacetResult, type UnifiedResultProjection, type UnifiedResultQuery, type UnifiedResultQueryResult } from '../../domain/unified-result';
|
||
import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../../domain/result-db-disclosure';
|
||
import { ResultsFilterPanel } from './ResultsFilterPanel';
|
||
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
|
||
import { FilterEmptyState } from '../../components/FilterEmptyState/FilterEmptyState';
|
||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||
import { browserDownloadAdapter, downloadBrowserBlob } from '../../platform/browser/browser-download-adapter';
|
||
import { downloadText } from '../../exports/text-download';
|
||
import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl';
|
||
import { hasSelectedTimeslotScope } from '../../app/work-scope';
|
||
import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels';
|
||
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
|
||
import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation';
|
||
import type { ResultDbMaterializationStatus } from '../../domain/result-db-materialization';
|
||
|
||
const assessorApi = new AssessorApiClient();
|
||
const PAGE_SIZE = 50;
|
||
const MAX_QUERY_PAGE_SIZE = 10000;
|
||
export const DEFAULT_RESULT_DB_FILTERS: UnifiedResultQuery = { deviation: true };
|
||
|
||
type BackendStatus = 'connected' | 'checking' | 'unavailable';
|
||
interface ResultsBrowserProps {
|
||
backendStatus: BackendStatus;
|
||
projectId?: string;
|
||
scanRunIds?: string[];
|
||
assessmentIds?: string[];
|
||
/** Work records holding the original of a row; the Result DB itself never shows originals. */
|
||
workRecordIds?(row: UnifiedResultProjection): string[];
|
||
onOpenInWork?(recordIds: string[]): void;
|
||
}
|
||
|
||
export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmentIds, workRecordIds, onOpenInWork }: ResultsBrowserProps) {
|
||
const [filters, setFilters] = useState<UnifiedResultQuery>(() => ({ ...DEFAULT_RESULT_DB_FILTERS }));
|
||
const [limit, setLimit] = useState(PAGE_SIZE);
|
||
const [loadAll, setLoadAll] = useState(false);
|
||
const [facetResult, setFacetResult] = useState<UnifiedResultFacetResult | null>(null);
|
||
const [resultDbStatus, setResultDbStatus] = useState<ResultDbMaterializationStatus | null>(null);
|
||
const [queryResult, setQueryResult] = useState<UnifiedResultQueryResult | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [exporting, setExporting] = useState<'jsonl' | 'excel' | 'csv' | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||
const hasTimeslotScope = hasSelectedTimeslotScope(scanRunIds, assessmentIds);
|
||
|
||
const load = useCallback(async () => {
|
||
if (backendStatus !== 'connected' || !projectId || !hasTimeslotScope) {
|
||
setFacetResult(null);
|
||
setQueryResult(null);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const scopedQuery: UnifiedResultQuery = {
|
||
...filters,
|
||
projectId,
|
||
scanRunIds,
|
||
assessmentIds
|
||
};
|
||
const [facets, firstPage] = await Promise.all([
|
||
assessorApi.queryUnifiedResultFacets(scopedQuery),
|
||
assessorApi.queryUnifiedResults({ ...scopedQuery, offset: 0, limit })
|
||
]);
|
||
const items = [...firstPage.items];
|
||
while (loadAll && items.length < firstPage.total) {
|
||
const nextPage = await assessorApi.queryUnifiedResults({
|
||
...scopedQuery,
|
||
offset: items.length,
|
||
limit: MAX_QUERY_PAGE_SIZE
|
||
});
|
||
if (!nextPage.items.length) break;
|
||
items.push(...nextPage.items);
|
||
}
|
||
setFacetResult(facets);
|
||
setQueryResult({ ...firstPage, items });
|
||
// Read after the rows: serving them guarantees the stored Result DB is current.
|
||
setResultDbStatus(await assessorApi.getResultDbStatus().catch(() => null));
|
||
} catch (reason) {
|
||
setError(reason instanceof Error ? reason.message : 'Results could not be loaded.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [backendStatus, projectId, scanRunIds, assessmentIds, hasTimeslotScope, filters, limit, loadAll]);
|
||
|
||
useEffect(() => { void load(); }, [load]);
|
||
|
||
const updateFilters = (next: UnifiedResultQuery) => {
|
||
setExpandedId(null);
|
||
setLimit(PAGE_SIZE);
|
||
setLoadAll(false);
|
||
setFilters(next);
|
||
};
|
||
const exportFilteredResults = async (format: 'jsonl' | 'excel' | 'csv') => {
|
||
if (!projectId || !hasTimeslotScope) return;
|
||
const activity = beginActivity('Core Engine', `Exporting Result DB as ${format === 'excel' ? 'Excel' : format.toUpperCase()}`);
|
||
setExporting(format);
|
||
setError(null);
|
||
try {
|
||
const scopedQuery: UnifiedResultQuery = { ...filters, projectId, scanRunIds, assessmentIds };
|
||
const results = await queryAllUnifiedResults(query => assessorApi.queryUnifiedResults(query), scopedQuery);
|
||
activity.update('Preparing the filtered export file');
|
||
await showActivityFrame();
|
||
const generatedAt = new Date().toISOString();
|
||
if (format === 'excel') {
|
||
const { buildUnifiedResultExcel, unifiedResultExcelFileName, RESULT_DB_EXCEL_MEDIA_TYPE } = await import('../../exports/unified-result-excel');
|
||
downloadBrowserBlob(new Blob([buildUnifiedResultExcel(scopedQuery, results, generatedAt)], { type: RESULT_DB_EXCEL_MEDIA_TYPE }), unifiedResultExcelFileName(generatedAt));
|
||
} else if (format === 'csv') {
|
||
downloadText(browserDownloadAdapter, unifiedResultCsvFileName(generatedAt), buildUnifiedResultCsv(scopedQuery, results, generatedAt), 'text/csv;charset=utf-8');
|
||
} else downloadText(
|
||
browserDownloadAdapter,
|
||
unifiedResultJsonlFileName(generatedAt),
|
||
buildUnifiedResultJsonl(scopedQuery, results, generatedAt),
|
||
'application/x-ndjson;charset=utf-8'
|
||
);
|
||
} catch (reason) {
|
||
activity.fail();
|
||
setError(reason instanceof Error ? reason.message : 'Filtered results could not be exported.');
|
||
} finally {
|
||
activity.complete();
|
||
setExporting(null);
|
||
}
|
||
};
|
||
const processingFacet = facetResult?.facets.find(facet => facet.id === 'processingStatus');
|
||
const processingWarnings = processingFacet?.values.filter(value =>
|
||
['unknown', 'incompatible', 'reimport-recommended'].includes(value.value)) ?? [];
|
||
|
||
if (backendStatus !== 'connected') {
|
||
return <div className="datahub-page">
|
||
<BackendConnectionDialog
|
||
backendStatus={backendStatus}
|
||
message="Results will be available again once the local API is connected."
|
||
/>
|
||
</div>;
|
||
}
|
||
|
||
if (!projectId) {
|
||
return <div className="datahub-page">
|
||
<div className="datahub-empty"><h3>No client selected</h3><p>Open a client in the Datahub first to browse its results.</p></div>
|
||
</div>;
|
||
}
|
||
|
||
if (!hasTimeslotScope) {
|
||
return <div className="datahub-page">
|
||
<div className="datahub-empty"><h3>No timeslot selected</h3><p>Select a timeslot in Work to browse exactly this assessment state.</p></div>
|
||
</div>;
|
||
}
|
||
|
||
return <div className="datahub-page results-browser-page">
|
||
{error &&<div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>}
|
||
{loading && !queryResult ? <PanelLoadingState message="Loading results …"/> : <div className="results-browser-layout">
|
||
<ResultsFilterPanel facets={facetResult?.facets ?? []} selected={filters} onChange={updateFilters}
|
||
onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })}/>
|
||
<div className="results-browser-table-wrap">
|
||
{processingWarnings.length > 0 && <div className="datahub-alert results-processing-alert" role="status">
|
||
<span><strong>Processing readiness:</strong> {processingWarnings.map(value => `${processingStatusLabel(value.value)} (${value.count.toLocaleString('en-US')})`).join(' · ')}. Comparisons with unknown or incompatible contracts are blocked; reimport the original source with the current profile where recommended.</span>
|
||
</div>}
|
||
<div className="page-actions-row">
|
||
<p className="db-structure-meta">{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}{resultDbStatus && <><br/><span className="results-db-status" data-state={resultDbStatus.state}>{resultDbStatusText(resultDbStatus)}</span></>}</p>
|
||
<div className="page-action-buttons">
|
||
<button className="btn" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
|
||
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}
|
||
onClick={() => void exportFilteredResults('jsonl')}>{exporting === 'jsonl' ? 'Exporting …' : 'Export JSONL'}</button>
|
||
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}
|
||
onClick={() => void exportFilteredResults('excel')}>{exporting === 'excel' ? 'Exporting …' : 'Export Excel'}</button>
|
||
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}
|
||
onClick={() => void exportFilteredResults('csv')}>{exporting === 'csv' ? 'Exporting …' : 'Export CSV'}</button>
|
||
</div>
|
||
</div>
|
||
{!queryResult?.items.length ? <FilterEmptyState title="No hits" onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })} onSearchAll={() => updateFilters({})}>No result matches the current filter combination.</FilterEmptyState> : <div className="results-browser-table" role="table" aria-label="Results">
|
||
<div className="results-browser-row results-browser-head" role="row">
|
||
<span>Title</span><span>Source Rule ID</span><span>Severity</span><span>Outcome</span><span>Result Type</span><span>IOE</span><span>Deviation</span><span>Tool</span><span>Processing</span>
|
||
</div>
|
||
{queryResult.items.map(item => <ResultRow key={item.resultProjectionId} item={item}
|
||
workRecordIds={workRecordIds?.(item) ?? []} onOpenInWork={onOpenInWork}
|
||
expanded={expandedId === item.resultProjectionId}
|
||
onToggle={() => setExpandedId(current => current === item.resultProjectionId ? null : item.resultProjectionId)}/>)}
|
||
</div>}
|
||
{queryResult && queryResult.total > queryResult.items.length && <div className="results-browser-load-actions">
|
||
<button className="btn" type="button" disabled={loading} onClick={() => setLimit(current => current + PAGE_SIZE)}>Load more</button>
|
||
<button className="btn" type="button" disabled={loading} onClick={() => loadAll ? void load() : setLoadAll(true)}>Load all</button>
|
||
</div>}
|
||
</div>
|
||
</div>}
|
||
</div>;
|
||
}
|
||
|
||
export function ResultRow({ item: sourceItem, expanded, onToggle, workRecordIds = [], onOpenInWork }: {
|
||
item: UnifiedResultProjection;
|
||
expanded: boolean;
|
||
onToggle(): void;
|
||
workRecordIds?: string[];
|
||
onOpenInWork?(recordIds: string[]): void;
|
||
}) {
|
||
const item = removeResultDbScopeIdentifiers(discloseResultDbProjection(sourceItem));
|
||
const environment = item.sourceKind === 'environment-observation';
|
||
const policyObservation = item.sourceKind === 'policy-observation';
|
||
const policyComparison = item.sourceKind === 'policy-evaluation' && Boolean(item.policyBaselineLabel);
|
||
const policyIdentity = displayPolicyIdentity(item);
|
||
const contextRecord = environment || policyObservation;
|
||
return <div className={`results-browser-row${expanded ? ' expanded' : ''}`} role="row">
|
||
<button className="results-browser-row-toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
|
||
<span data-label="Title" className="results-browser-title"><TriangleIcon direction={expanded ? 'down' : 'right'} />{item.title}</span>
|
||
<span data-label="Source Rule ID" title={item.sourceRuleId}>{item.sourceRuleId ?? '–'}</span>
|
||
<span data-label="Severity">{contextRecord ? 'Context' : item.severityNormalized}</span>
|
||
<span data-label="Outcome" className={contextRecord ? '' : 'outcome-mark'} data-outcome={contextRecord ? undefined : item.outcomeNormalized}>{environment ? item.environmentState : policyObservation ? item.policyIdentityStatus : item.sourceKind === 'policy-evaluation' && item.policyCustomerState === 'missing' && item.outcomeNormalized === 'informational' ? 'Not configured' : item.outcomeNormalized}</span>
|
||
<span data-label="Result Type">{environment ? 'Environment' : policyObservation ? 'GPO Observation' : item.resultType}</span>
|
||
<span data-label="IOE">{contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'}</span>
|
||
<span data-label="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
|
||
<span data-label="Tool">{resultSourceToolLabel(item.sourceTool)}</span>
|
||
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span> </button>
|
||
{expanded && <div className="results-browser-row-detail">
|
||
{environment && <div className="results-environment-value">
|
||
<strong>{item.environmentProperty}</strong>
|
||
<p>{displayEnvironmentValue(item.environmentNormalizedValue)}</p>
|
||
</div>}
|
||
{policyObservation && <div className="results-environment-value">
|
||
<strong>{item.policyIdentityKey}</strong>
|
||
<p>{displayEnvironmentValue(item.policyValues)}</p>
|
||
</div>}
|
||
{item.description && <p>{item.description}</p>}
|
||
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
|
||
{policyComparison && <div className="results-environment-value">
|
||
<strong>Expected baseline value</strong>
|
||
<p>{displayPolicyValue(item.policyBaselineValue)}</p>
|
||
</div>}
|
||
<ul>
|
||
<li>Source: {item.sourceProfileId ?? '–'}@{item.sourceProfileVersion ?? '–'}</li>
|
||
<li>Processing: profile {item.sourceProfileVersion ?? '–'} · mapping {item.mappingRuleVersion ?? '–'} · normalization {item.normalizationRuleVersion ?? '–'} · presentation {item.presentationVersion ?? '–'}</li>
|
||
<li>Compatibility: {item.processingCompatibilityKey ?? 'unknown'} · {processingStatusLabel(item.processingStatus)}</li>
|
||
{item.policyClassification && <li>Result Status: {POLICY_CLASSIFICATION_LABELS[item.policyClassification]}{item.policySourceCount ? ` · ${item.policySourceCount.toLocaleString('en-US')} sources` : ''}</li>}
|
||
{item.processingStatus === 'reimport-recommended' && <li>Reprocessing: {item.reprocessingAvailable ? 'retained source available' : 'original source must be uploaded again'}</li>}
|
||
{environment ? <>
|
||
<li>Subject class: {item.environmentSubjectType ?? '–'}</li>
|
||
<li>State: {item.environmentState ?? 'unknown'} · Value type: {item.environmentValueType ?? 'unknown'}</li>
|
||
<li>Disclosure: {item.supplementalDisclosureClass ?? '–'} · policy {item.supplementalDisclosurePolicyVersion ?? '–'}</li>
|
||
</> : policyObservation ? <>
|
||
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
|
||
<li>Policy state: {item.policyState ?? '–'} · Identity: {item.policyIdentityStatus ?? 'unresolved'}</li>
|
||
<li>Backup instance: {item.policyBackupInstanceId ?? '–'}</li>
|
||
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
|
||
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li>
|
||
</> : policyComparison ? <>
|
||
{policyIdentity && <li>Technical setting ID: {policyIdentity}</li>}
|
||
<li>Baseline: {item.policyBaselineLabel}</li>
|
||
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
|
||
{item.policyBaselineVariantAssessment ? <li>
|
||
{disclosedBaselineDecision(item.policyBaselineVariantAssessment.decision)}
|
||
<p>No effective device state or GPO application is established by this backup comparison.</p>
|
||
<ul>{item.policyBaselineVariantAssessment.variants.map(variant => <li key={variant.settingId}>
|
||
Value comparison: {variant.comparator.relation}
|
||
<p className="policy-guidance-text">Expected: {displayPolicyValue(variant.comparator.baseline)}</p>
|
||
<p className="policy-guidance-text">Original baseline value: {displayPolicyValue(variant.baselineValues)}</p>
|
||
</li>)}</ul>
|
||
</li> : null}
|
||
<li>Comparison: {item.policyComparisonRelation ?? '–'} · Customer state: {item.policyCustomerState ?? '–'}</li>
|
||
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
|
||
</> : <>
|
||
<li>Rule ID: {item.sourceRuleId ?? '–'}</li>
|
||
<li>Affected Object: {item.affectedAsset ?? '–'}</li>
|
||
</>}
|
||
<li>Categories: {item.categories.map(category => category.categoryLabel).join(', ') || '–'}</li>
|
||
</ul>
|
||
<DisclosureTraceDetail item={item} workRecordIds={workRecordIds} onOpenInWork={onOpenInWork}/>
|
||
</div>}
|
||
</div>;
|
||
}
|
||
|
||
function displayEnvironmentValue(value: unknown): string {
|
||
if (value === undefined) return '–';
|
||
if (typeof value === 'string') return value || '–';
|
||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||
if (value === null) return 'null';
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
function displayPolicyValue(value: unknown): string {
|
||
return value === undefined ? '–' : formatPolicyComparisonValue(JSON.stringify(value));
|
||
}
|
||
|
||
/** Result DB collapses identical baseline expectations, so it states the decision without a variant count. */
|
||
function disclosedBaselineDecision(decision: string): string {
|
||
if (decision === 'all-pass') return 'Matches every assessed baseline expectation (backup configuration only)';
|
||
if (decision === 'all-fail') return 'Differs from every assessed baseline expectation (backup configuration only)';
|
||
if (decision === 'mixed') return 'Review required: baseline expectations lead to different results';
|
||
return 'Review required: comparison evidence or applicability is incomplete';
|
||
}
|
||
|
||
function displayPolicyIdentity(item: UnifiedResultProjection): string | undefined {
|
||
const identity = item.policyIdentityKey ?? item.sourceRuleId;
|
||
return identity && !/^gpo-policy-identity-[0-9a-f]+$/i.test(identity) ? identity : undefined;
|
||
}
|
||
|
||
function processingStatusLabel(value: string | undefined): string {
|
||
if (value === 'current') return 'Current';
|
||
if (value === 'compatible-older') return 'Compatible older';
|
||
if (value === 'reimport-recommended') return 'Reimport recommended';
|
||
if (value === 'incompatible') return 'Incompatible';
|
||
return 'Unknown';
|
||
}
|
||
|
||
const traceFieldLabels: Record<string, string> = {
|
||
title: 'Title', description: 'Description', recommendation: 'Recommendation', sourceRuleId: 'Source Rule ID',
|
||
policyBaselineLabel: 'Baseline', policyBaselineValue: 'Expected baseline value',
|
||
policyBaselineVariantAssessment: 'Baseline expectations', environmentNormalizedValue: 'Value', categories: 'Categories'
|
||
};
|
||
|
||
function resultDbStatusText(status: ResultDbMaterializationStatus): string {
|
||
if (status.state === 'not-built') return 'Result DB not built yet';
|
||
const checked = status.materializedAt ? status.materializedAt.slice(0, 16).replace('T', ' ') : '–';
|
||
return [
|
||
status.state === 'current' ? `Checked ${checked} UTC` : `Outdated: checked ${checked} UTC, rebuilding`,
|
||
`policy ${status.disclosurePolicyVersion ?? '–'}`,
|
||
`identity guard ${status.identityGuardVersion ?? '–'}`,
|
||
`${status.rowCount.toLocaleString('en-US')} rows`,
|
||
`${status.redactionCount.toLocaleString('en-US')} redactions`,
|
||
`${status.guardWithheldRowCount.toLocaleString('en-US')} rows with withheld fields`
|
||
].join(' · ');
|
||
}
|
||
|
||
/** Content-free trace of the stored row: what was redacted and withheld, never the original. */
|
||
function DisclosureTraceDetail({ item, workRecordIds, onOpenInWork }: {
|
||
item: UnifiedResultProjection;
|
||
workRecordIds: string[];
|
||
onOpenInWork?(recordIds: string[]): void;
|
||
}) {
|
||
const trace = item.disclosureTrace;
|
||
if (!trace) return null;
|
||
const byField = new Map<string, string[]>();
|
||
for (const entry of trace.redactions) {
|
||
byField.set(entry.field, [...(byField.get(entry.field) ?? []), `${entry.rule} × ${entry.count.toLocaleString('en-US')}`]);
|
||
}
|
||
const hasOriginal = item.sourceKind === 'scanner-finding' || item.sourceKind === 'policy-evaluation';
|
||
return <div className="results-disclosure-trace">
|
||
<strong>Checked content</strong>
|
||
<ul>
|
||
<li>Policy {trace.disclosurePolicyVersion} · identity guard {trace.identityGuardVersion}</li>
|
||
{[...byField].map(([field, rules]) => <li key={field}>Redacted in {traceFieldLabels[field] ?? field}: {rules.join(', ')}</li>)}
|
||
{!byField.size && <li>No redaction in the disclosed fields</li>}
|
||
{trace.withheldFields.length > 0 && <li>Withheld fields: {trace.withheldFields.join(', ')}</li>}
|
||
{trace.guardWithheldFields.length > 0 && <li>Withheld by identity guard: {trace.guardWithheldFields.map(field => traceFieldLabels[field] ?? field).join(', ')}</li>}
|
||
</ul>
|
||
{hasOriginal && (workRecordIds.length && onOpenInWork
|
||
? <button className="btn" type="button" onClick={() => onOpenInWork(workRecordIds)}>Open original in Work</button>
|
||
: <p>The original is not part of the loaded Work scope.</p>)}
|
||
</div>;
|
||
}
|