183 lines
12 KiB
TypeScript
183 lines
12 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||
import type {
|
||
PolicyAnalysisOverview,
|
||
PolicyAnalysisReviewCategory,
|
||
PolicyAnalysisReviewItem
|
||
} from '../../domain/policy-analysis-overview';
|
||
import { PolicyAnalysisActions } from './PolicyAnalysisActions';
|
||
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
|
||
import { queryAllUnifiedResults } from '../../exports/unified-result-jsonl';
|
||
import { currentPolicyEvaluationsForWork } from './load-canonical-project-workspace';
|
||
import { buildPolicyWorkResults, countPolicyWorkResults, policyMisconfigurationImpact, type PolicyWorkCounts } from '../../evaluation/policy-work-results';
|
||
|
||
const assessorApi = new AssessorApiClient();
|
||
|
||
const categoryLabels: Record<PolicyAnalysisReviewCategory, string> = {
|
||
conflict: 'Conflict',
|
||
'unresolved-identity': 'Unresolved setting',
|
||
'catalog-gap': 'Baseline mapping unavailable',
|
||
'parser-gap': 'Unsupported setting format',
|
||
'partial-analysis': 'Evaluation incomplete',
|
||
'targeting-unknown': 'Targeting not resolved',
|
||
'incomplete-evidence': 'Source evidence incomplete',
|
||
other: 'Review required'
|
||
};
|
||
|
||
function formatTimestamp(value: string): string {
|
||
return new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value));
|
||
}
|
||
|
||
function ReviewRow({ item }: { item: PolicyAnalysisReviewItem }) {
|
||
return <details className={`policy-review-item category-${item.category}`}>
|
||
<summary>
|
||
<strong>{categoryLabels[item.category]}</strong>
|
||
<span>{item.observationCount} observation{item.observationCount === 1 ? '' : 's'} · {item.backupCount} GPO source{item.backupCount === 1 ? '' : 's'}</span>
|
||
</summary>
|
||
<div className="policy-review-detail">
|
||
<span>Source: {item.source}</span>
|
||
{item.identityId && <span>Technical identity: <code>{item.identityId}</code></span>}
|
||
<span>Diagnostic{item.codes.length === 1 ? '' : 's'}: {item.codes.map(code => <code key={code}>{code}</code>)}</span>
|
||
</div>
|
||
</details>;
|
||
}
|
||
|
||
export function PolicyAnalysisPanelView({ overview, counts, impact }: { overview: PolicyAnalysisOverview; counts?: PolicyWorkCounts; impact?: ReturnType<typeof policyMisconfigurationImpact> }) {
|
||
const visibleReviews = overview.current.reviewItems.slice(0, 100);
|
||
const currentSummary = overview.current.summary;
|
||
const profileById = new Map(overview.profiles.map(profile => [profile.id, profile]));
|
||
const hasPipelineData = overview.summary.identityAnalysisRuns > 0 || overview.summary.aggregationRuns > 0
|
||
|| overview.summary.evaluationRuns > 0 || overview.summary.findingRuns > 0;
|
||
return <section className="policy-analysis-panel" aria-label="Policy analysis results">
|
||
<div className="datahub-section-heading">
|
||
<div><span>GPO Analysis</span><h3>Baseline Evaluation</h3></div>
|
||
</div>
|
||
<p className="policy-analysis-boundary" role="note">
|
||
Backup settings are compared with the imported Microsoft Security Baselines. Effective application requires RSoP or GP Results.
|
||
</p>
|
||
{!hasPipelineData ? <div className="datahub-empty policy-analysis-empty">
|
||
<h3>No policy analysis runs yet</h3>
|
||
<p>Imported GPO backups remain processing evidence until snapshot aggregation and baseline evaluation have run.</p>
|
||
</div> : <>
|
||
<div className="policy-analysis-result-groups">
|
||
<section className="policy-analysis-result-group" aria-labelledby="gpo-import-summary">
|
||
<h4 id="gpo-import-summary">Processing</h4>
|
||
<div className="policy-analysis-summary">
|
||
<article><span>Analysis runs</span><strong>{currentSummary.identityAnalysisRuns}</strong></article>
|
||
<article><span>Evaluation runs</span><strong>{currentSummary.evaluationRuns}</strong></article>
|
||
<article><span>GPO backups with failing settings</span><strong>{impact?.backups ?? '–'}</strong><small>Inspect source-linked comparisons with the same-named filter in Work.</small></article>
|
||
</div>
|
||
</section>
|
||
<section className="policy-analysis-result-group" aria-labelledby="gpo-result-summary">
|
||
<h4 id="gpo-result-summary">Policy verdicts</h4>
|
||
<p className="policy-analysis-muted">One verdict per technical policy setting.</p>
|
||
<div className="policy-analysis-summary">
|
||
<article data-result="fail"><span>Misconfigured</span><strong>{counts?.Fail ?? '–'}</strong></article>
|
||
<article data-result="pass"><span>Pass</span><strong>{counts?.Pass ?? '–'}</strong></article>
|
||
<article><span>Not configured</span><strong>{counts?.['Not configured'] ?? '–'}</strong></article>
|
||
<article><span>Review required</span><strong>{counts?.['Review required'] ?? '–'}</strong></article>
|
||
<article><span>No baseline rule</span><strong>{counts?.['No baseline rule'] ?? '–'}</strong><small>Configured settings with no applicable Microsoft baseline recommendation. Score-neutral.</small></article>
|
||
<article><span>Not comparable</span><strong>{counts?.['Not comparable'] ?? '–'}</strong><small>Group Policy Preferences. No comparable technical policy setting. Score-neutral.</small></article>
|
||
</div>
|
||
</section>
|
||
<section className="policy-analysis-result-group" aria-labelledby="gpo-diagnostic-summary">
|
||
<h4 id="gpo-diagnostic-summary">Diagnostic flags</h4>
|
||
<p className="policy-analysis-muted">May overlap with each other and with policy verdicts. Score-neutral.</p>
|
||
<div className="policy-analysis-summary policy-analysis-problems">
|
||
<article><span>Conflicts</span><strong>{counts?.Conflicts ?? '–'}</strong><small>Different customer values for one technical setting.</small></article>
|
||
<article><span>Duplicates</span><strong>{counts?.Duplicates ?? '–'}</strong><small>Identical values from multiple GPO sources.</small></article>
|
||
</div>
|
||
</section>
|
||
<section className="policy-analysis-result-group" aria-labelledby="gpo-gap-summary">
|
||
<h4 id="gpo-gap-summary">Analysis gaps</h4>
|
||
<p className="policy-analysis-muted">Observation-level diagnostics. No score impact.</p>
|
||
<div className="policy-analysis-summary policy-analysis-problems">
|
||
<article><span>Unresolved identity</span><strong>{currentSummary.unresolvedIdentities}</strong><small>Observations that could not be mapped to one technical policy setting. Their settings appear in Work as Not comparable.</small></article>
|
||
<article><span>Comparison not supported</span><strong>{currentSummary.parserGaps}</strong><small>The retained value cannot yet be compared safely; the setting stays under Review required.</small></article>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<details className="policy-analysis-technical">
|
||
<summary>Technical details</summary>
|
||
<div className="policy-analysis-columns">
|
||
<section>
|
||
<div className="policy-analysis-subheading"><h4>Evaluation history</h4><span>{overview.evaluations.length}</span></div>
|
||
{!overview.evaluations.length ? <p className="policy-analysis-muted">No baseline evaluation has been persisted yet.</p>
|
||
: <div className="policy-evaluation-list">{overview.evaluations.map(run => <article key={run.evaluationRunId}>
|
||
<header><strong>{profileById.get(run.environmentProfileId)?.name ?? 'Environment profile'} · {run.status === 'complete' ? 'complete' : run.status}</strong><span>{formatTimestamp(run.completedAt)}</span></header>
|
||
<div><span>{run.matchCount} matches</span><span>{run.deviationCount} stored mismatches (including missing settings)</span><span>{run.notApplicableCount} not applicable</span><span>{run.reviewCount} review required</span></div>
|
||
<footer><strong>{run.findingCount} confirmed finding{run.findingCount === 1 ? '' : 's'}</strong>{run.rejectedFindingCount > 0 && <span>{run.rejectedFindingCount} factory rejection{run.rejectedFindingCount === 1 ? '' : 's'}</span>}</footer>
|
||
</article>)}</div>}
|
||
</section>
|
||
<section>
|
||
<div className="policy-analysis-subheading"><h4>Current review queue</h4><span>{overview.current.reviewItems.length}</span></div>
|
||
{!visibleReviews.length ? <p className="policy-analysis-muted">No conflicts or unresolved evidence in the persisted runs.</p>
|
||
: <div className="policy-review-list">{visibleReviews.map(item => <ReviewRow item={item} key={item.id}/>)}</div>}
|
||
{overview.current.reviewItems.length > visibleReviews.length && <p className="policy-analysis-muted">Showing the first 100 deterministic review hints.</p>}
|
||
</section>
|
||
</div>
|
||
</details>
|
||
</>}
|
||
</section>;
|
||
}
|
||
|
||
export function PolicyAnalysisPanel({ projectId }: { projectId: string }) {
|
||
const [overview, setOverview] = useState<PolicyAnalysisOverview>();
|
||
const [counts, setCounts] = useState<PolicyWorkCounts>();
|
||
const [impact, setImpact] = useState<ReturnType<typeof policyMisconfigurationImpact>>();
|
||
const [error, setError] = useState<string>();
|
||
const [loading, setLoading] = useState(true);
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const [nextOverview, observations, evaluations] = await Promise.all([
|
||
assessorApi.getPolicyAnalysisOverview(projectId),
|
||
queryAllUnifiedResults(query => assessorApi.queryUnifiedResults(query), {
|
||
audience: 'internal-analysis', projectId, sourceKind: ['policy-observation']
|
||
}),
|
||
assessorApi.listPolicyEvaluations(projectId)
|
||
]);
|
||
setOverview(nextOverview);
|
||
const currentEvaluationIds = new Set(nextOverview.current.evaluationRunIds);
|
||
const workResults = buildPolicyWorkResults(observations,
|
||
currentPolicyEvaluationsForWork(evaluations.records, new Set(observations.map(source => source.sourceResultId)), currentEvaluationIds));
|
||
setCounts(countPolicyWorkResults(workResults));
|
||
setImpact(policyMisconfigurationImpact(workResults));
|
||
setError(undefined);
|
||
} catch (reason) {
|
||
setCounts(undefined);
|
||
setImpact(undefined);
|
||
setError(reason instanceof Error ? reason.message : 'Policy analysis could not be loaded.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [projectId]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
const refresh = (event: Event) => {
|
||
if (event instanceof CustomEvent && event.detail?.projectId && event.detail.projectId !== projectId) return;
|
||
void load();
|
||
};
|
||
globalThis.addEventListener('assessor:gpo-changed', refresh);
|
||
globalThis.addEventListener('focus', refresh);
|
||
return () => {
|
||
globalThis.removeEventListener('assessor:gpo-changed', refresh);
|
||
globalThis.removeEventListener('focus', refresh);
|
||
};
|
||
}, [load, projectId]);
|
||
|
||
if (loading && !overview) return <section className="policy-analysis-panel"><PanelLoadingState message="Loading policy analysis …" compact/></section>;
|
||
if (error && !overview) return <section className="policy-analysis-panel"><div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div></section>;
|
||
return overview ? <>
|
||
{error && <div className="datahub-alert" role="alert">Analysis could not be refreshed.</div>}
|
||
<PolicyAnalysisPanelView overview={overview} counts={counts} impact={impact}/>
|
||
<details className="policy-analysis-controls">
|
||
<summary>Processing controls</summary>
|
||
<PolicyAnalysisActions projectId={projectId} overview={overview} onChanged={async () => {
|
||
await load();
|
||
globalThis.dispatchEvent(new CustomEvent('assessor:gpo-changed', { detail: { projectId } }));
|
||
}}/>
|
||
</details>
|
||
</> : null;
|
||
}
|