Assessor_2.0/src/features/project-import/PolicyAnalysisActions.tsx

163 lines
12 KiB
TypeScript

import { useState, type FormEvent } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { OperationProgressDialog } from '../../components/OperationProgress/OperationProgress';
import type {
PolicyAnalysisActionStatus,
PolicyAnalysisOverview
} from '../../domain/policy-analysis-overview';
import type { CreateEnvironmentProfileRequest, EnvironmentProfileScope } from '../../domain/gpo-snapshot-aggregation';
const assessorApi = new AssessorApiClient();
interface ContextDraft {
productFamily: string;
productVersion: string;
role: string;
scope: EnvironmentProfileScope;
}
interface ProfileDraft {
name: string;
domainKey: string;
supersedesProfileId: string;
operatingSystem: ContextDraft;
includeProduct: boolean;
product: ContextDraft;
}
const emptyContext = (): ContextDraft => ({ productFamily: '', productVersion: '', role: '', scope: 'computer' });
const emptyProfile = (): ProfileDraft => ({
name: '', domainKey: '', supersedesProfileId: '', operatingSystem: emptyContext(), includeProduct: false, product: emptyContext()
});
function contextId(kind: 'operating-system' | 'product', context: ContextDraft): string {
return [kind, context.productFamily, context.productVersion, context.role, context.scope]
.map(value => value.trim().toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''))
.join(':');
}
function shortId(value: string): string {
return value.length > 22 ? `${value.slice(0, 10)}…${value.slice(-8)}` : value;
}
function actionLabel(status: PolicyAnalysisActionStatus): string {
if (status === 'completed') return 'Completed';
if (status === 'blocked') return 'Blocked';
return status === 'review-only' ? 'Run as review-only' : 'Run';
}
function ReasonCodes({ codes }: { codes: string[] }) {
return codes.length ? <span className="policy-action-reasons">{codes.map(code => <code key={code}>{code}</code>)}</span> : null;
}
export function PolicyAnalysisActions({ projectId, overview, onChanged }: {
projectId: string;
overview: PolicyAnalysisOverview;
onChanged(): Promise<void>;
}) {
const [draft, setDraft] = useState<ProfileDraft>(emptyProfile);
const [busyId, setBusyId] = useState<string>();
const [progress, setProgress] = useState<string>();
const [feedback, setFeedback] = useState<{ tone: 'success' | 'error'; message: string }>();
const profileById = new Map(overview.profiles.map(profile => [profile.id, profile]));
const run = async (id: string, action: () => Promise<unknown>, success: string, progressLabel: string) => {
setBusyId(id);
setProgress(progressLabel);
setFeedback(undefined);
try {
await action();
setFeedback({ tone: 'success', message: success });
await onChanged();
return true;
} catch (reason) {
setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'The policy action failed.' });
return false;
} finally {
setBusyId(undefined);
setProgress(undefined);
}
};
const createProfile = async (event: FormEvent) => {
event.preventDefault();
const os = draft.operatingSystem;
const contexts: CreateEnvironmentProfileRequest['contexts'] = [{
id: contextId('operating-system', os), kind: 'operating-system' as const,
productFamily: os.productFamily.trim(), productVersion: os.productVersion.trim(),
role: os.role.trim(), scope: os.scope
}];
if (draft.includeProduct) {
const product = draft.product;
contexts.push({
id: contextId('product', product), kind: 'product',
productFamily: product.productFamily.trim(), productVersion: product.productVersion.trim(),
role: product.role.trim(), scope: product.scope
});
}
const created = await run('create-profile', () => assessorApi.createEnvironmentProfile(projectId, {
name: draft.name.trim(), domainKey: draft.domainKey.trim() || undefined,
supersedesProfileId: draft.supersedesProfileId || undefined, contexts
}), 'Environment profile persisted.', 'Saving profile');
if (created) setDraft(emptyProfile());
};
const updateContext = (key: 'operatingSystem' | 'product', patch: Partial<ContextDraft>) => {
setDraft(current => ({ ...current, [key]: { ...current[key], ...patch } }));
};
const validOs = draft.name.trim() && draft.operatingSystem.productFamily.trim()
&& draft.operatingSystem.productVersion.trim() && draft.operatingSystem.role.trim();
const validProduct = !draft.includeProduct || draft.product.productFamily.trim()
&& draft.product.productVersion.trim() && draft.product.role.trim();
return <section className="policy-orchestration" aria-label="Policy analysis orchestration">
<div className="datahub-section-heading">
<div><span>Analysis Workflow</span><h3>Profiles and Runs</h3></div>
<strong>{overview.profiles.length}</strong>
</div>
<p className="policy-analysis-muted">Only actions derived from persisted project and catalog evidence are offered. Review-only runs remain ineligible for findings unless every downstream invariant is satisfied.</p>
{feedback && <div className={`tenant-save-feedback ${feedback.tone}`} role={feedback.tone === 'error' ? 'alert' : 'status'}><span aria-hidden="true">{feedback.tone === 'success' ? '✓' : '!'}</span><strong>{feedback.message}</strong></div>}
<details className="policy-profile-editor">
<summary>Create Environment Profile</summary>
<form onSubmit={event => void createProfile(event)}>
<label>Profile name<input className="input" required maxLength={160} value={draft.name} onChange={event => setDraft(current => ({ ...current, name: event.target.value }))}/></label>
<label>Domain key <span>optional</span><input className="input" maxLength={255} value={draft.domainKey} onChange={event => setDraft(current => ({ ...current, domainKey: event.target.value }))}/></label>
<label>Supersedes <span>optional</span><select className="input" value={draft.supersedesProfileId} onChange={event => setDraft(current => ({ ...current, supersedesProfileId: event.target.value }))}><option value="">New profile chain</option>{overview.profiles.filter(profile => profile.active).map(profile => <option value={profile.id} key={profile.id}>{profile.name} · revision {profile.revision}</option>)}</select></label>
<fieldset><legend>Operating system context</legend>
<label>Product family<input className="input" required maxLength={160} placeholder="Windows Server" value={draft.operatingSystem.productFamily} onChange={event => updateContext('operatingSystem', { productFamily: event.target.value })}/></label>
<label>Version<input className="input" required maxLength={80} placeholder="2025" value={draft.operatingSystem.productVersion} onChange={event => updateContext('operatingSystem', { productVersion: event.target.value })}/></label>
<label>Role<input className="input" required maxLength={120} placeholder="Member Server" value={draft.operatingSystem.role} onChange={event => updateContext('operatingSystem', { role: event.target.value })}/></label>
<label>Scope<select className="input" value={draft.operatingSystem.scope} onChange={event => updateContext('operatingSystem', { scope: event.target.value as EnvironmentProfileScope })}><option value="computer">Computer</option><option value="user">User</option><option value="mixed">Mixed</option></select></label>
</fieldset>
<label className="policy-profile-product-toggle"><input type="checkbox" checked={draft.includeProduct} onChange={event => setDraft(current => ({ ...current, includeProduct: event.target.checked }))}/> Add product context</label>
{draft.includeProduct && <fieldset><legend>Product context</legend>
<label>Product family<input className="input" required maxLength={160} placeholder="Microsoft Edge" value={draft.product.productFamily} onChange={event => updateContext('product', { productFamily: event.target.value })}/></label>
<label>Version<input className="input" required maxLength={80} value={draft.product.productVersion} onChange={event => updateContext('product', { productVersion: event.target.value })}/></label>
<label>Role<input className="input" required maxLength={120} placeholder="Any" value={draft.product.role} onChange={event => updateContext('product', { role: event.target.value })}/></label>
<label>Scope<select className="input" value={draft.product.scope} onChange={event => updateContext('product', { scope: event.target.value as EnvironmentProfileScope })}><option value="computer">Computer</option><option value="user">User</option><option value="mixed">Mixed</option></select></label>
</fieldset>}
<footer><button className="btn primary" type="submit" disabled={busyId !== undefined || !validOs || !validProduct}>{busyId === 'create-profile' ? 'Saving …' : 'Create Profile'}</button></footer>
</form>
</details>
<div className="policy-action-stages">
<section><h4>Reprocessing</h4>{!overview.actions.reprocess.length ? <p>No processed GPO source available.</p> : overview.actions.reprocess.slice(0, 20).map(action => <article key={action.id}>
<div><strong>GPO source</strong><code title={action.artifactId}>{shortId(action.artifactId)}</code><ReasonCodes codes={action.reasonCodes}/></div>
<button className="btn compact" type="button" disabled={busyId !== undefined || action.status === 'blocked'} onClick={() => void run(action.id, () => assessorApi.reprocessGpoBackup(projectId, action.artifactId, action.expectedLatestProcessingRunId!), 'GPO reprocessing queued.', 'Starting GPO processing')}>{busyId === action.id ? 'Queuing …' : action.status === 'blocked' ? 'Blocked' : 'Reprocess'}</button>
</article>)}</section>
<section><h4>Snapshot aggregation</h4>{!overview.actions.aggregate.length ? <p>Create a profile and process a GPO backup first.</p> : overview.actions.aggregate.slice(0, 20).map(action => <article key={action.id}>
<div><strong>{profileById.get(action.environmentProfileId)?.name ?? 'Environment profile'}</strong><span>{action.sourceAnalysisRunIds.length} identity run</span><ReasonCodes codes={action.reasonCodes}/></div>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.aggregatePolicySnapshot(projectId, { environmentProfileId: action.environmentProfileId, sourceAnalysisRunIds: action.sourceAnalysisRunIds }), 'Snapshot aggregation persisted.', 'Calculating snapshot')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
</article>)}</section>
<section><h4>Baseline evaluation</h4>{!overview.actions.evaluate.length ? <p>No snapshot aggregation available.</p> : overview.actions.evaluate.slice(0, 20).map(action => <article key={action.id}>
<div><strong>{profileById.get(action.environmentProfileId)?.name ?? 'Environment profile'}</strong><span>{action.baselineAnalysisIds.length} baseline analysis source{action.baselineAnalysisIds.length === 1 ? '' : 's'}</span><ReasonCodes codes={action.reasonCodes}/></div>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.evaluatePolicy(projectId, { aggregationRunId: action.aggregationRunId, baselineAnalysisIds: action.baselineAnalysisIds }), 'Policy evaluation persisted.', 'Evaluating baseline')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
</article>)}</section>
<section><h4>Finding Factory</h4>{!overview.actions.createFindings.length ? <p>No policy evaluation available.</p> : overview.actions.createFindings.slice(0, 20).map(action => <article key={action.id}>
<div><strong>{action.eligibleResultCount} eligible result{action.eligibleResultCount === 1 ? '' : 's'}</strong><code title={action.evaluationRunId}>{shortId(action.evaluationRunId)}</code><ReasonCodes codes={action.reasonCodes}/></div>
<button className="btn compact" type="button" disabled={busyId !== undefined || ['completed', 'blocked'].includes(action.status)} onClick={() => void run(action.id, () => assessorApi.createPolicyFindingRun(projectId, { evaluationRunId: action.evaluationRunId }), 'Finding Factory run persisted.', 'Creating findings')}>{busyId === action.id ? 'Running …' : actionLabel(action.status)}</button>
</article>)}</section>
</div>
<OperationProgressDialog label={progress}/>
</section>;
}