Assessor_2.0/src/features/results-browser/ResultsBrowser.tsx

352 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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