Assessor_2.0/src/app/AppProvider.tsx

727 lines
40 KiB
TypeScript

import { beginActivity, showActivityFrame } from '../platform/activity-store';
import { readEnginePreferences, useEnginePreferences } from './engine-preferences';
import { preparedAssessmentContext } from '../api/prepared-assessment-context';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react';
import { AssessorApiClient } from '../api/assessor-api-client';
import type { DatasetSheet, ImportedDataset } from '../domain/dataset';
import type { FindingReview } from '../domain/canonical-model';
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
import type { ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance';
import { defaultReview } from '../domain/review';
import type { ImportProfile } from '../domain/import-profile';
import type { ImportBatchPreview, ImportWorkflowState } from '../domain/import-workflow';
import type { Review } from '../domain/review';
import { importFile as importFileLocally } from '../importers/importer';
import { BrowserStorageAdapter } from '../platform/browser/browser-storage-adapter';
import { LocalStorageReviewRepository } from '../persistence/review-store';
import { createSnapshot, parseSnapshot } from '../persistence/snapshot-store';
import { genericProfile } from '../profiles/generic/generic-profile';
import { importProfiles } from '../profiles/profile-catalog';
import { stableProjectId } from '../shared/identifiers';
import { combineDatasets } from '../features/project-import/project-import';
import { setServerMitreMatrix } from '../features/mitre-graph/mitre-graph';
import { loadCanonicalProjectWorkspace } from '../features/project-import/load-canonical-project-workspace';
import { profileForDataset, profileForSheet } from '../features/project-import/workspace-profile';
export { profileForSheet } from '../features/project-import/workspace-profile';
import {
confirmEntryMappings, createBrowserFingerprintStore, createImportReport, enterImportPreview, failedPreviewEntry,
LAST_IMPORT_REPORT_STORAGE_KEY, preparePreviewEntriesForReview, previewEntryFromDataset, recognitionOverridesFromResult, sha256File
} from '../features/import-workflow/import-workflow';
import { updateReviewState } from '../features/review-workspace/update-review-state';
import {
hydrateWorkspaceReviews, recordsByOccurrence, reviewFromDatabase, reviewsEqual,
reviewToDatabase, reviewTargetKey
} from '../features/review-workspace/database-review';
import type { DynamicFacetFilter } from '../features/search-filter/dynamic-facets';
import { filterAndSortRecords } from '../features/search-filter/filter-records';
import { applySavedViewState, initialAppState, type AppState, type SavedViewState, type ThemeMode, type ViewMode } from './app-state';
import { createWorkScopeId } from './work-scope';
const profiles: ImportProfile[] = importProfiles;
const reviewRepository = new LocalStorageReviewRepository(new BrowserStorageAdapter());
const assessorApi = new AssessorApiClient();
interface AppContextValue extends AppState {
activeSheet?: DatasetSheet;
activeProfile: ImportProfile;
importWorkflow: ImportWorkflowState;
importFiles(files: FileList | File[], targetProjectId?: string, targetAssessmentId?: string, replacesScanRunId?: string): Promise<void>;
setImportEntrySelected(entryId: string, selected: boolean): void;
confirmImportEntry(entryId: string): void;
commitImportBatch(): Promise<void>;
closeImportWorkflow(): void;
setActiveSheet(sheetId: string): void;
recordRiskAcceptance(recordId: string, command: RiskAcceptanceCommand): Promise<void>;
recordScopedRiskAcceptance(write: ScopedRiskAcceptanceWrite): Promise<void>;
setView(view: ViewMode): void;
setSearch(search: string): void;
setSortColumn(column: string): void;
toggleSortDirection(): void;
setTitleColumn(column: string): void;
setStatusFilter(filter: string): void;
setFacetFilter(facetId: string, filter: DynamicFacetFilter | null): void;
setActionOnly: (value: boolean) => void;
setPathFindingIds(recordIds: string[]): void;
applyViewBookmark(view: SavedViewState): void;
targetProjectId?: string;
activeScanRunIds?: string[];
activeAssessmentIds?: string[];
workScopeId: string;
setTargetProjectId: (projectId?: string) => void;
openProject(projectId: string, scanRunIds?: string[], assessmentIds?: string[], clientName?: string): Promise<boolean>;
setSelectedRecord(recordId: string): void;
updateReview(recordId: string, patch: Partial<Review>): void;
snapshot(): ReturnType<typeof createSnapshot> | null;
setTheme(mode: ThemeMode): void;
setDelimiter(delimiter: string): void;
toggleSidebar(): void;
setSidebarCollapsed(collapsed: boolean): void;
resetFilters(): void;
toggleWorkspace(): void;
}
const AppContext = createContext<AppContextValue | null>(null);
const profileFor = profileForDataset;
function initialSheetStatus(_sheet: DatasetSheet | undefined, _defaultStatus: string): string {
return 'deviation';
}
function effectiveTheme(mode: ThemeMode): 'dark' | 'light' {
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
export function AppProvider({ children }: PropsWithChildren) {
const [importWorkflow, setImportWorkflow] = useState<ImportWorkflowState>({ phase: 'idle' });
const [targetProjectId, setTargetProjectId] = useState<string | undefined>();
const [activeScanRunIds, setActiveScanRunIds] = useState<string[] | undefined>();
const [activeAssessmentIds, setActiveAssessmentIds] = useState<string[] | undefined>();
const [workScopeId, setWorkScopeId] = useState('');
const openProjectRequest = useRef(0);
const openProjectIdentity = useRef<{ id: string; name: string } | undefined>(undefined);
const persistedReviews = useRef(new Map<string, FindingReview>());
const reviewSaveQueues = useRef(new Map<string, Promise<void>>());
const [state, setState] = useState<AppState>(() => {
const stored =
(localStorage.getItem('ASSESSOR.theme') as ThemeMode | null) ??
(localStorage.getItem('exxTableReader.theme') as ThemeMode | null) ??
'dark';
localStorage.setItem('ASSESSOR.theme', stored);
document.documentElement.dataset.theme = effectiveTheme(stored);
return { ...initialAppState, theme: stored };
});
const engines = useEnginePreferences();
useEffect(() => {
setState(current => {
const facetFilters = { ...current.facetFilters };
if (!engines.context) delete facetFilters['context:cluster'];
if (!engines.context || !engines.report) delete facetFilters['report:business-impact'];
if (Object.keys(facetFilters).length === Object.keys(current.facetFilters).length) return current;
return { ...current, facetFilters };
});
}, [engines.context, engines.report, state.facetFilters]);
useEffect(() => {
let active = true;
void assessorApi.getMitreCatalog().then(state => {
if (active) setServerMitreMatrix(state.catalog);
}).catch(() => undefined);
return () => { active = false; };
}, []);
useEffect(() => {
if (!workScopeId) return;
const persist = () => { void reviewRepository.save(workScopeId, state.reviews); };
if ('requestIdleCallback' in window) {
const idleId = window.requestIdleCallback(persist, { timeout: 750 });
return () => { window.cancelIdleCallback(idleId); persist(); };
}
const timeoutId = globalThis.setTimeout(persist, 150);
return () => { globalThis.clearTimeout(timeoutId); persist(); };
}, [state.reviews, workScopeId]);
useEffect(() => {
if (!state.dataset || !state.projectId || state.backendStatus !== 'connected') return;
const timer = globalThis.setTimeout(() => {
for (const [occurrenceId, recordIds] of recordsByOccurrence(state.dataset!)) {
const desired = recordIds.map(recordId => state.reviews[recordId]).find(Boolean);
if (!desired) continue;
const key = `occurrence:${occurrenceId}`;
const stored = persistedReviews.current.get(key);
if (stored && reviewsEqual(desired, reviewFromDatabase(stored))) continue;
const previous = reviewSaveQueues.current.get(key) ?? Promise.resolve();
const queued = previous.catch(() => undefined).then(async () => {
const current = persistedReviews.current.get(key);
if (current && reviewsEqual(desired, reviewFromDatabase(current))) return;
const review = reviewToDatabase({ ...desired, riskAcceptance: current?.riskAcceptance }, { kind: 'occurrence', occurrenceId }, current, new Date().toISOString());
await assessorApi.saveReview(review, current?.revision ?? 0);
persistedReviews.current.set(reviewTargetKey(review.target), review);
}).catch(() => {
setState(current => current.projectId === state.projectId
? { ...current, status: 'Review could not be saved to the database. The local copy was kept.' }
: current);
});
reviewSaveQueues.current.set(key, queued);
}
}, 350);
return () => globalThis.clearTimeout(timer);
}, [state.backendStatus, state.dataset, state.projectId, state.reviews]);
useEffect(() => {
let active = true;
const checkBackend = async () => {
try {
const health = await assessorApi.health();
if (active) setState(current => {
const backendStatus = health.status === 'ok' ? 'connected' : 'unavailable';
return current.backendStatus === backendStatus ? current : { ...current, backendStatus };
});
} catch {
if (active) setState(current => current.backendStatus === 'unavailable' ? current : { ...current, backendStatus: 'unavailable' });
}
};
void checkBackend();
const intervalId = globalThis.setInterval(() => { void checkBackend(); }, 10_000);
const handleFocus = () => { void checkBackend(); };
globalThis.addEventListener('focus', handleFocus);
return () => {
active = false;
globalThis.clearInterval(intervalId);
globalThis.removeEventListener('focus', handleFocus);
};
}, []);
const importFiles = useCallback(async (fileCollection: FileList | File[], targetProjectId?: string, targetAssessmentId?: string, replacesScanRunId?: string) => {
const files = [...fileCollection];
if (!files.length) return;
let preparedPreview: ImportBatchPreview | undefined;
setImportWorkflow({ phase: 'preparing', targetProjectId, targetAssessmentId, replacesScanRunId });
setState(current => ({ ...current, status: `Checking ${files.length === 1 ? files[0].name : `${files.length} files`}...` }));
try {
if (replacesScanRunId && state.backendStatus !== 'connected') {
throw new Error('A safe reimport requires the connected backend to verify the exact SHA-256 identity of the original scan. No data was imported.');
}
if (files.length === 1 && files[0].name.toLocaleLowerCase('en-US').endsWith('.json')) {
const snapshot = parseSnapshot(await files[0].text());
if (snapshot) {
const projectId = stableProjectId(snapshot.dataset.fileName, snapshot.dataset.fileSize, snapshot.dataset.lastModified);
const profile = profileFor(snapshot.dataset);
const activeSheet = snapshot.dataset.sheets[0];
const defaults = profile.defaults(activeSheet);
setState(current => ({
...current, dataset: snapshot.dataset, activeSheetId: activeSheet?.id ?? '', reviews: snapshot.reviews, scopedRiskAcceptances: [],
projectId, titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection, facetFilters: {}, pathFindingIds: [],
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', actionOnly: false, view: 'columns',
status: 'Snapshot loaded'
}));
setWorkScopeId(projectId);
setImportWorkflow({ phase: 'idle' });
await reviewRepository.save(projectId, snapshot.reviews);
return;
}
}
let preview: ImportBatchPreview;
if (state.backendStatus === 'connected') {
preview = await assessorApi.previewImportBatch(files, state.delimiter, targetProjectId, targetAssessmentId, replacesScanRunId);
if (replacesScanRunId && preview.replacesScanRunId !== replacesScanRunId) {
throw new Error('The backend does not support the current safe reimport contract. Restart the local app and backend before retrying.');
}
} else {
const createdAt = new Date().toISOString();
const batchId = `batch-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const hashes = await Promise.all(files.map(sha256File));
const known = new Map(createBrowserFingerprintStore(localStorage).load().map(item => [item.sha256, item]));
const firstInBatch = new Map<string, string>();
const entries = [];
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
const sha256 = hashes[index];
const fingerprint = { sha256, fileName: file.name, sizeBytes: file.size, lastModified: file.lastModified };
const duplicateOf = firstInBatch.get(sha256) ?? known.get(sha256)?.fileName;
try {
const dataset = await importFileLocally(file, { profiles, delimiter: state.delimiter });
entries.push({ ...previewEntryFromDataset(dataset, fingerprint, duplicateOf), id: `${batchId}:${index}` });
if (!firstInBatch.has(sha256)) firstInBatch.set(sha256, file.name);
} catch (error) {
entries.push({
...failedPreviewEntry(fingerprint, error instanceof Error ? error.message : 'Unknown parser error'),
id: `${batchId}:${index}`
});
}
}
preview = { version: 1, batchId, createdAt, entries };
}
// Im expliziten Reimport ist ein Byte-Treffer die erwartete Ersatzquelle
// und bleibt daher ausgewaehlt. Beim normalen Einzelimport gilt weiterhin
// die bestehende explizite Nutzerentscheidung fuer genau diese Datei.
const entriesToProcess = preparePreviewEntriesForReview(preview.entries, {
reimport: Boolean(replacesScanRunId), singleFile: files.length === 1
});
const reviewPreview: ImportBatchPreview = { ...preview, entries: entriesToProcess };
preparedPreview = reviewPreview;
const selected = entriesToProcess.filter(entry => entry.selected && entry.dataset && entry.status !== 'error');
const hasErrors = entriesToProcess.some(entry => entry.status === 'error');
// Ein bereits vorhandener Bericht (gleiche Bytes oder derselbe Bericht in
// einem anderen Ausgabeformat) darf nie still ein zweites Mal aufgenommen
// werden - hier muss der Prüfdialog mit dem Hinweis erscheinen.
const hasKnownReport = entriesToProcess.some(entry => entry.duplicateOf || entry.possibleDuplicateOf);
if (selected.length > 0 && !hasErrors && !hasKnownReport && selected.every(entry => entry.mappingsToConfirm === 0)) {
const imported = selected.map(entry => entry.dataset!);
const knownProfiles = [...new Set(imported.map(dataset => dataset.profileId).filter(id => id !== 'generic'))];
const profile = knownProfiles.length === 1
? profiles.find(candidate => candidate.id === knownProfiles[0]) ?? genericProfile
: genericProfile;
const dataset = imported.length === 1 ? imported[0] : combineDatasets(imported, profile);
const selections = selected.map(entry => ({
entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified,
recognitionOverrides: recognitionOverridesFromResult(entry.dataset?.recognition, entry.dataset?.mapping)
}));
let persistedProjectId: string | undefined;
if (state.backendStatus === 'connected') {
if (replacesScanRunId && preview.replacesScanRunId !== replacesScanRunId) {
throw new Error('The reimport preview is not bound to the selected scan. Restart the local app and backend before retrying.');
}
const completion = await assessorApi.completeImportBatch(preview.batchId, selections, targetProjectId, targetAssessmentId, replacesScanRunId);
if (replacesScanRunId && completion.replacedScanRunId !== replacesScanRunId) {
throw new Error('The backend did not confirm the selected replacement target.');
}
persistedProjectId = completion.projectId;
} else {
createBrowserFingerprintStore(localStorage).record(selections.map(({ entryId: _entryId, recognitionOverrides: _overrides, ...fingerprint }) => fingerprint));
}
const importedIds = new Set(selected.map(entry => entry.id));
const report = createImportReport(preview, importedIds);
localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report));
const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified);
const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId
? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])])
: [undefined, []];
const workDataset = persistedWorkspace?.dataset ?? dataset;
const localReviews = await reviewRepository.load(projectId);
const hydrated = persistedWorkspace
? hydrateWorkspaceReviews(workDataset, persistedWorkspace.models.flatMap(model => model.reviews), localReviews)
: { reviews: localReviews, persisted: new Map<string, FindingReview>() };
persistedReviews.current = hydrated.persisted;
const reviews = hydrated.reviews;
const activeSheet = workDataset.sheets[0];
const activeProfile = profileFor(workDataset);
const defaults = activeProfile.defaults(activeSheet);
setState(current => ({
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false,
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
facetFilters: {}, pathFindingIds: [], status: `${report.importedFiles} file${report.importedFiles === 1 ? '' : 's'} imported · ${report.importedRecords.toLocaleString('en-US')} records`
}));
setWorkScopeId(projectId);
setImportWorkflow({ phase: 'idle' });
globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } }));
} else {
setImportWorkflow(current => enterImportPreview(current, reviewPreview));
const usable = preview.entries.filter(entry => entry.dataset && entry.status !== 'duplicate').length;
setState(current => ({ ...current, status: `${files.length} file${files.length === 1 ? '' : 's'} checked · ${usable} preselected` }));
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(error);
const failedBeforePreview = !preparedPreview;
setImportWorkflow(current => enterImportPreview(
current,
preparedPreview ?? { version: 1, batchId: `failed-${Date.now()}`, createdAt: new Date().toISOString(), entries: [] },
failedBeforePreview ? message : `Import not executed: ${message}`
));
setState(current => ({ ...current, status: failedBeforePreview
? `Import check failed: ${message}`
: `Import not executed: ${message}` }));
}
}, [state.backendStatus, state.delimiter]);
const setImportEntrySelected = useCallback((entryId: string, selected: boolean) => {
setImportWorkflow(current => current.preview ? {
...current,
preview: {
...current.preview,
entries: current.preview.entries.map(entry => entry.id === entryId && entry.status !== 'error' && entry.dataset
? { ...entry, selected }
: entry)
}
} : current);
}, []);
const confirmImportEntry = useCallback((entryId: string) => {
setImportWorkflow(current => current.preview ? {
...current,
preview: { ...current.preview, entries: current.preview.entries.map(entry => entry.id === entryId ? confirmEntryMappings(entry, profiles) : entry) }
} : current);
}, []);
const closeImportWorkflow = useCallback(() => {
setImportWorkflow({ phase: 'idle' });
}, []);
const commitImportBatch = useCallback(async () => {
const preview = importWorkflow.preview;
const targetProjectId = importWorkflow.targetProjectId;
const targetAssessmentId = importWorkflow.targetAssessmentId;
const replacesScanRunId = importWorkflow.replacesScanRunId;
if (!preview) return;
const selected = preview.entries.filter(entry => entry.selected && entry.dataset && entry.status !== 'error');
if (!selected.length) {
setImportWorkflow(current => ({ ...current, error: 'Select at least one successfully checked file.' }));
return;
}
if (selected.some(entry => entry.mappingsToConfirm > 0)) {
setImportWorkflow(current => ({ ...current, error: 'Confirm all suggested source, sheet, and field mappings first.' }));
return;
}
setImportWorkflow(current => ({ ...current, phase: 'committing', error: undefined }));
try {
const imported = selected.map(entry => entry.dataset!);
const knownProfiles = [...new Set(imported.map(dataset => dataset.profileId).filter(id => id !== 'generic'))];
const profile = knownProfiles.length === 1
? profiles.find(candidate => candidate.id === knownProfiles[0]) ?? genericProfile
: genericProfile;
const dataset = imported.length === 1 ? imported[0] : combineDatasets(imported, profile);
const selections = selected.map(entry => ({
entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified,
recognitionOverrides: recognitionOverridesFromResult(entry.dataset?.recognition, entry.dataset?.mapping)
}));
let persistedProjectId: string | undefined;
if (state.backendStatus === 'connected') {
if (replacesScanRunId && preview.replacesScanRunId !== replacesScanRunId) {
throw new Error('The reimport preview is not bound to the selected scan. Restart the local app and backend before retrying.');
}
const completion = await assessorApi.completeImportBatch(preview.batchId, selections, targetProjectId, targetAssessmentId, replacesScanRunId);
if (replacesScanRunId && completion.replacedScanRunId !== replacesScanRunId) {
throw new Error('The backend did not confirm the selected replacement target.');
}
persistedProjectId = completion.projectId;
} else {
createBrowserFingerprintStore(localStorage).record(selections.map(({ entryId: _entryId, recognitionOverrides: _overrides, ...fingerprint }) => fingerprint));
}
const importedIds = new Set(selected.map(entry => entry.id));
const report = createImportReport(preview, importedIds);
localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report));
const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified);
const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId
? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])])
: [undefined, []];
const workDataset = persistedWorkspace?.dataset ?? dataset;
const localReviews = await reviewRepository.load(projectId);
const hydrated = persistedWorkspace
? hydrateWorkspaceReviews(workDataset, persistedWorkspace.models.flatMap(model => model.reviews), localReviews)
: { reviews: localReviews, persisted: new Map<string, FindingReview>() };
persistedReviews.current = hydrated.persisted;
const reviews = hydrated.reviews;
const activeSheet = workDataset.sheets[0];
const activeProfile = profileFor(workDataset);
const defaults = activeProfile.defaults(activeSheet);
setState(current => ({
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false,
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
facetFilters: {}, pathFindingIds: [], status: `${report.importedFiles} file${report.importedFiles === 1 ? '' : 's'} imported · ${report.importedRecords.toLocaleString('en-US')} records`
}));
setWorkScopeId(projectId);
setImportWorkflow({ phase: 'report', preview, report });
globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } }));
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(error);
setImportWorkflow(current => ({ ...current, phase: 'preview', error: `Import not executed: ${message}` }));
setState(current => ({ ...current, status: `Import not executed: ${message}` }));
}
}, [importWorkflow.preview, state.backendStatus]);
const openProject = useCallback(async (projectId: string, scanRunIds?: string[], selectedAssessmentIds?: string[], clientName?: string): Promise<boolean> => {
const activity = beginActivity('Core Engine', 'Opening client and selected timeslot');
const requestId = ++openProjectRequest.current;
const scopeId = createWorkScopeId(projectId, scanRunIds, selectedAssessmentIds);
const loadingClientName = clientName?.trim()
|| (openProjectIdentity.current?.id === projectId ? openProjectIdentity.current.name : '')
|| projectId;
openProjectIdentity.current = { id: projectId, name: loadingClientName };
setTargetProjectId(projectId);
// Der fachliche Scope wechselt atomar mit dem Client-/Timeslot-Auftrag.
// Result DB und Graph duerfen waehrend des asynchronen Workspace-Ladens
// weder den vorherigen Timeslot noch den gesamten neuen Client anzeigen.
setActiveScanRunIds(scanRunIds?.length ? [...new Set(scanRunIds)] : undefined);
setActiveAssessmentIds(selectedAssessmentIds);
setState(current => ({
...current,
dataset: null,
activeSheetId: '',
projectId,
status: `Loading Client: ${loadingClientName}`
}));
try {
await showActivityFrame();
const [{ models, dataset, assessmentIds }, scopedRiskAcceptances] = await Promise.all([
loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds, selectedAssessmentIds, { onProgress: activity.update }),
assessorApi.listScopedRiskAcceptances(projectId).catch(() => [])
]);
if (requestId !== openProjectRequest.current) { activity.cancel(); return false; }
if (models[0]?.project.name) openProjectIdentity.current = { id: projectId, name: models[0].project.name };
if (!models.length) {
persistedReviews.current = new Map();
setState(current => ({ ...current, scopedRiskAcceptances, status: 'This client does not have a scan report yet.' }));
return false;
}
if (!dataset) {
persistedReviews.current = new Map(models.flatMap(model => model.reviews).map(review => [reviewTargetKey(review.target), review]));
setActiveAssessmentIds(assessmentIds);
setWorkScopeId(scopeId);
setState(current => ({ ...current, reviews: {}, scopedRiskAcceptances, selectedRecordId: '', status: 'Client opened. No displayable results are available in the selected timeslot yet.' }));
return true;
}
activity.update('Loading work tracking and preparing the client view');
await showActivityFrame();
const localReviews = await reviewRepository.load(scopeId);
const hydrated = hydrateWorkspaceReviews(dataset, models.flatMap(model => model.reviews), localReviews);
persistedReviews.current = hydrated.persisted;
const reviews = hydrated.reviews;
if (requestId !== openProjectRequest.current) { activity.cancel(); return false; }
let contextNotice = '';
if (readEnginePreferences().context && assessmentIds.length) {
activity.update('Preparing Context Engine before opening the client');
try {
await preparedAssessmentContext(assessorApi, dataset, projectId, assessmentIds, scanRunIds);
} catch {
// Context is derived: a failure must not make the Core results unavailable.
contextNotice = ' · Context could not be loaded. Reopen the client to retry.';
}
if (requestId !== openProjectRequest.current) { activity.cancel(); return false; }
}
const activeSheet = dataset.sheets[0];
const profile = profileFor(dataset);
const defaults = profile.defaults(activeSheet);
setState(current => ({
...current,
dataset,
activeSheetId: activeSheet?.id ?? '',
reviews,
scopedRiskAcceptances,
projectId,
search: '',
actionOnly: false,
view: 'columns',
selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '',
titleColumn: defaults.titleColumn,
sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection,
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
facetFilters: {},
pathFindingIds: [],
status: models[0].project.name + ' · ' + dataset.scanTool + ' · ' + models.length.toLocaleString('en-US') + ' scan report' + (models.length === 1 ? '' : 's') + ' · ' + activeSheet.analysis.records.length.toLocaleString('en-US') + ' records loaded' + contextNotice
}));
setActiveAssessmentIds(assessmentIds);
setWorkScopeId(scopeId);
return true;
} catch (reason) {
activity.fail();
if (requestId !== openProjectRequest.current) { activity.cancel(); return false; }
const message = reason instanceof Error ? reason.message : 'Unknown loading error';
setState(current => ({ ...current, dataset: null, activeSheetId: '', status: 'Client could not be loaded: ' + message }));
return false;
} finally { activity.complete(); }
}, []);
const updateReview = useCallback((recordId: string, patch: Partial<Review>) => {
setState(current => {
const selected = current.dataset?.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.id === recordId);
const linkedRecordIds = selected?.findingOccurrenceId && current.dataset
? recordsByOccurrence(current.dataset).get(selected.findingOccurrenceId) ?? [recordId]
: [recordId];
let next = { reviews: current.reviews, actionOnly: current.actionOnly };
for (const linkedRecordId of linkedRecordIds) next = updateReviewState(next, linkedRecordId, patch);
const { reviews, actionOnly } = next;
return { ...current, reviews, actionOnly };
});
}, []);
const setTheme = useCallback((theme: ThemeMode) => {
localStorage.setItem('ASSESSOR.theme', theme);
document.documentElement.dataset.theme = effectiveTheme(theme);
setState(current => ({ ...current, theme }));
}, []);
const activeSheet = state.dataset?.sheets.find(sheet => sheet.id === state.activeSheetId) ?? state.dataset?.sheets[0];
const activeProfile = profileForSheet(state.dataset, activeSheet);
const value = useMemo<AppContextValue>(() => ({
...state,
activeSheet,
activeProfile,
importWorkflow,
importFiles,
setImportEntrySelected,
confirmImportEntry,
commitImportBatch,
closeImportWorkflow,
setActiveSheet: activeSheetId => setState(current => {
const sheet = current.dataset?.sheets.find(candidate => candidate.id === activeSheetId);
const defaults = profileForSheet(current.dataset, sheet).defaults(sheet);
return {
...current,
activeSheetId,
selectedRecordId: sheet?.analysis.records[0]?.id ?? '',
titleColumn: defaults.titleColumn,
sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection,
statusFilter: initialSheetStatus(sheet, defaults.statusFilter),
facetFilters: {},
pathFindingIds: []
};
}),
setView: view => setState(current => ({ ...current, view })),
setSearch: search => setState(current => ({ ...current, search, selectedRecordId: '' })),
setSortColumn: sortColumn => setState(current => ({ ...current, sortColumn })),
toggleSortDirection: () => setState(current => ({ ...current, sortDirection: current.sortDirection === 'asc' ? 'desc' : 'asc' })),
setTitleColumn: titleColumn => setState(current => ({ ...current, titleColumn })),
setStatusFilter: statusFilter => setState(current => ({ ...current, statusFilter })),
setFacetFilter: (facetId, filter) => setState(current => {
const facetFilters = { ...current.facetFilters };
if (!filter || (!filter.values?.length && !filter.minimum && !filter.maximum)) delete facetFilters[facetId];
else facetFilters[facetId] = filter;
return { ...current, facetFilters, selectedRecordId: '' };
}),
setActionOnly: actionOnly => setState(current => ({ ...current, actionOnly })),
setPathFindingIds: recordIds => setState(current => {
const pathFindingIds = [...new Set(recordIds.filter(Boolean))];
const selectedRecordId = pathFindingIds.includes(current.selectedRecordId)
? current.selectedRecordId
: (current.activeSheetId
? current.dataset?.sheets.find(sheet => sheet.id === current.activeSheetId)?.analysis.records.find(record => pathFindingIds.includes(record.id))?.id ?? ''
: '');
return { ...current, pathFindingIds, selectedRecordId };
}),
applyViewBookmark: savedView => setState(current => applySavedViewState(current, savedView)),
targetProjectId,
activeScanRunIds,
activeAssessmentIds,
workScopeId,
setTargetProjectId: projectId => {
setTargetProjectId(projectId);
if (!projectId) {
openProjectIdentity.current = undefined;
setActiveScanRunIds(undefined);
setActiveAssessmentIds(undefined);
}
},
openProject,
setSelectedRecord: selectedRecordId => setState(current => ({ ...current, selectedRecordId })),
updateReview,
recordRiskAcceptance: async (recordId, command) => {
const record = state.dataset?.sheets.flatMap(sheet => sheet.analysis.records).find(item => item.id === recordId);
if (!targetProjectId || !record?.findingOccurrenceId || state.backendStatus !== 'connected') throw new Error('Connect to the backend and open a saved finding before recording risk acceptance.');
const occurrenceId = record.findingOccurrenceId;
const key = `occurrence:${occurrenceId}`;
const persisted = persistedReviews.current;
const previous = reviewSaveQueues.current.get(key) ?? Promise.resolve();
const queued = previous.catch(() => undefined).then(async () => {
let current = persisted.get(key);
const desired = { ...defaultReview(), ...state.reviews[recordId], riskAcceptance: current?.riskAcceptance };
if (!current || !reviewsEqual(desired, reviewFromDatabase(current))) {
const review = reviewToDatabase(desired, { kind: 'occurrence', occurrenceId }, current, new Date().toISOString());
const saved = await assessorApi.saveReview(review, current?.revision ?? 0);
current = saved.review;
persisted.set(key, current);
}
const saved = await assessorApi.recordRiskAcceptance(targetProjectId, occurrenceId, command, current.revision);
persisted.set(key, saved.review);
setState(latest => {
if (latest.dataset !== state.dataset) return latest;
const reviews = { ...latest.reviews };
for (const id of recordsByOccurrence(latest.dataset!).get(occurrenceId) ?? []) {
reviews[id] = { ...defaultReview(), ...reviews[id], riskAcceptance: saved.review.riskAcceptance };
}
return { ...latest, reviews };
});
});
reviewSaveQueues.current.set(key, queued);
return queued;
},
recordScopedRiskAcceptance: async write => {
if (!targetProjectId || state.backendStatus !== 'connected') throw new Error('Connect to the backend and open a saved Defender scope before recording risk acceptance.');
const saved = await assessorApi.recordScopedRiskAcceptance(targetProjectId, write);
setState(current => ({
...current,
scopedRiskAcceptances: [saved.acceptance, ...current.scopedRiskAcceptances.filter(item => item.id !== saved.acceptance.id)]
}));
},
snapshot: () => state.dataset ? createSnapshot(state.dataset, state.reviews) : null,
setTheme,
setDelimiter: delimiter => setState(current => ({ ...current, delimiter })),
toggleSidebar: () => setState(current => ({ ...current, sidebarCollapsed: !current.sidebarCollapsed })),
setSidebarCollapsed: sidebarCollapsed => setState(current => current.sidebarCollapsed === sidebarCollapsed ? current : { ...current, sidebarCollapsed }),
resetFilters: () => setState(current => {
const profile = profileForSheet(current.dataset, activeSheet);
const defaults = profile.defaults(activeSheet);
const statusFilter = initialSheetStatus(activeSheet, defaults.statusFilter);
// Der gerade gelesene Fund bleibt ausgewaehlt, solange er im
// Standardfilter vorkommt - sonst sprang die Ansicht auf den ersten
// Datensatz und man verlor die Stelle, an der man gerade gearbeitet hat.
// Die Ersatzwahl nutzt bewusst dieselbe Filterung und Sortierung wie die
// Trefferliste, damit sie auf dem obersten sichtbaren Fund landet und
// nicht auf dem ersten der unsortierten Blattreihenfolge.
const visible = filterAndSortRecords(activeSheet, {
activeProfile: profile,
search: '',
sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection,
statusFilter,
facetFilters: {},
dataset: current.dataset,
actionOnly: current.actionOnly,
reviews: current.reviews
});
const keepsSelection = visible.some(record => record.id === current.selectedRecordId);
return {
...current,
search: '',
sortColumn: defaults.sortColumn,
sortDirection: defaults.sortDirection,
titleColumn: defaults.titleColumn,
statusFilter,
facetFilters: {},
pathFindingIds: [],
selectedRecordId: keepsSelection ? current.selectedRecordId : (visible[0]?.id ?? '')
};
}),
toggleWorkspace: () => setState(current => {
if (!Object.values(current.reviews).some(review => review.include)) return current;
return { ...current, actionOnly: !current.actionOnly, view: 'columns' };
})
}), [activeProfile, activeAssessmentIds, activeScanRunIds, activeSheet, closeImportWorkflow, commitImportBatch, confirmImportEntry, importFiles, importWorkflow, setImportEntrySelected, setTheme, state, updateReview, targetProjectId, workScopeId, openProject]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
/** Optional scope for read-only additions to standalone Finding Readers. */
export function useOptionalApp(): AppContextValue | null {
return useContext(AppContext);
}
export function useApp(): AppContextValue {
const context = useOptionalApp();
if (!context) throw new Error('useApp must be used within AppProvider.');
return context;
}
/** Retained views keep their last visible inputs without doing hidden derivation work. */
export function RetainedAppState({ active, children }: PropsWithChildren<{ active: boolean }>) {
const current = useApp();
const lastVisible = useRef(current);
if (active) lastVisible.current = current;
return <AppContext.Provider value={lastVisible.current}>{children}</AppContext.Provider>;
}