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

831 lines
51 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { Assessment, AssessmentProject, CanonicalAssessmentModel, ImportFileRecord, ScanRun } from '../../domain/canonical-model';
import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '../../api/assessor-api-contract';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { Dialog } from '../../components/Dialog/Dialog';
import { OperationProgressDialog } from '../../components/OperationProgress/OperationProgress';
import { FileImport } from '../file-import/FileImport';
import { stableAssessmentId, stableProjectId } from '../../shared/identifiers';
import { groupProjectImportFiles, type ScanImportGroup } from './datahub-import-groups';
import { GpoFolderImport } from './GpoFolderImport';
import { PolicyAnalysisPanel } from './PolicyAnalysisPanel';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
import { clearStoredMitreMatrix, loadStoredMitreMatrix, saveStoredMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
import { TriangleIcon } from '../../components/TriangleIcon';
import { DatahubHistoryOverview, type DatahubTimeslotSelection } from './DatahubHistoryOverview';
import { comparableTimeslotSources, summarizeTimeslotResults, type DatahubTimeslotResultMetrics } from './datahub-scan-analysis';
import { datahubScanReadiness, isDatahubScanViewable, type DatahubScanReadiness } from './datahub-scan-readiness';
import { summarizeScanProcessingReadiness } from './datahub-processing-readiness';
import { compareReimportSourceHashes } from '../../domain/reimport-source';
import { loadTimeslotOpenReadiness, type TimeslotOpenReadiness } from './datahub-open-readiness';
const assessorApi = new AssessorApiClient();
const SHOW_MITRE_MATRIX_LIBRARY = false;
type BackendStatus = 'connected' | 'checking' | 'unavailable';
interface DataHubProps {
activeProjectId?: string;
backendStatus: BackendStatus;
onProjectSelected(project?: AssessmentProject, scanRunIds?: string[], assessmentIds?: string[]): void;
onProjectDataChanged?(projectId: string): void | Promise<void>;
onDetailViewChanged?(isDetailView: boolean, clientName?: string): void;
}
interface ProjectFormState {
mode: 'create' | 'edit';
project?: AssessmentProject;
name: string;
description: string;
consultant: string;
}
interface SaveFeedback {
tone: 'success' | 'error';
message: string;
}
type ScanReadinessState = DatahubScanReadiness | 'checking' | 'unavailable';
type TenantDetailSection = 'client-information' | 'gpo-analysis';
const gpoStateLabels: Record<GpoBackupStatusRecord['state'], string> = {
queued: 'Queued',
running: 'Processing',
succeeded: 'Ready',
partial: 'Imported',
'dead-letter': 'Failed',
'recovery-required': 'Recovery pending'
};
function projectConsultant(project: AssessmentProject): string {
return typeof project.attributes.consultant === 'string' ? project.attributes.consultant : '';
}
function formatTenantDate(value: string): string {
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(value));
}
function formatHistoryDate(value: string): string {
return new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value));
}
function groupScanReports(files: ImportFileRecord[], scanRuns: ScanRun[] = []): ScanImportGroup[] {
const runById = new Map(scanRuns.map(run => [run.id, run]));
const filesByRun = new Map<string, ImportFileRecord[]>();
for (const file of files) filesByRun.set(file.scanRunId, [...(filesByRun.get(file.scanRunId) ?? []), file]);
return [...filesByRun.entries()].map(([scanRunId, runFiles]) => {
const run = runById.get(scanRunId);
const fileDate = run?.completedAt ?? run?.startedAt ?? runFiles[0].lastModifiedAt ?? runFiles[0].importedAt;
const importedAt = runFiles.reduce((latest, file) => Date.parse(file.importedAt) > Date.parse(latest) ? file.importedAt : latest, runFiles[0].importedAt);
return {
id: scanRunId,
scanRunId,
scanRunIds: [scanRunId],
assessmentIds: run ? [run.assessmentId] : [],
tool: run?.tool ?? runFiles[0].detectedTool,
files: runFiles,
gpoBackups: [],
sourceCount: 1,
fileCount: runFiles.length,
fileNames: runFiles.map(file => file.fileName),
formats: [...new Set(runFiles.map(file => file.format))],
sizeBytes: runFiles.reduce((sum, file) => sum + file.sizeBytes, 0),
fileDate,
importedAt,
possibleDuplicateGroupIds: []
};
}).sort((left, right) => Date.parse(right.fileDate) - Date.parse(left.fileDate));
}
export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChanged, onDetailViewChanged, backendStatus }: DataHubProps) {
const [projects, setProjects] = useState<AssessmentProject[]>([]);
const [projectData, setProjectData] = useState<Record<string, ImportFileRecord[]>>({});
const [projectHistory, setProjectHistory] = useState<Record<string, ProjectAssessmentHistoryResponse>>({});
const [projectGpoData, setProjectGpoData] = useState<Record<string, GpoBackupStatusRecord[]>>({});
const [form, setForm] = useState<ProjectFormState | null>(null);
const [masterDataEditing, setMasterDataEditing] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<AssessmentProject | null>(null);
const [scanDeleteTarget, setScanDeleteTarget] = useState<ScanImportGroup | null>(null);
const [gpoDeleteTarget, setGpoDeleteTarget] = useState<GpoBackupStatusRecord | null>(null);
const [timeslotDeleteTarget, setTimeslotDeleteTarget] = useState<ScanImportGroup | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [operationProgress, setOperationProgress] = useState<string>();
const [processingGpoJob, setProcessingGpoJob] = useState<{ projectId: string; artifactId: string; jobId: string }>();
const [error, setError] = useState<string | null>(null);
const [saveFeedback, setSaveFeedback] = useState<SaveFeedback | null>(null);
const [mitreStatus, setMitreStatus] = useState(() => loadStoredMitreMatrix());
const [mitreError, setMitreError] = useState<string | null>(null);
const [mitreFeedback, setMitreFeedback] = useState<string | null>(null);
const [scanSelection, setScanSelection] = useState<DatahubTimeslotSelection | null>(null);
const [timeslotMetrics, setTimeslotMetrics] = useState<DatahubTimeslotResultMetrics | null>(null);
const [timeslotMetricsBusy, setTimeslotMetricsBusy] = useState(false);
const [timeslotMetricsError, setTimeslotMetricsError] = useState<string | null>(null);
const [selectedTimeslotIds, setSelectedTimeslotIds] = useState<string[]>([]);
const [excludedScanRunIds, setExcludedScanRunIds] = useState<string[]>([]);
const [expandedTimeslotIds, setExpandedTimeslotIds] = useState<string[]>([]);
const [timeslotDialogOpen, setTimeslotDialogOpen] = useState(false);
const [timeslotDate, setTimeslotDate] = useState('');
const [scanReadiness, setScanReadiness] = useState<Record<string, ScanReadinessState>>({});
const [projectOpenReadiness, setProjectOpenReadiness] = useState<Record<string, Record<string, TimeslotOpenReadiness>>>({});
const [detailSection, setDetailSection] = useState<TenantDetailSection>('client-information');
const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>());
useEffect(() => {
onDetailViewChanged?.(form !== null, form?.mode === 'edit' ? form.name.trim() || form.project?.name : undefined);
}, [form, onDetailViewChanged]);
useEffect(() => () => onDetailViewChanged?.(false), [onDetailViewChanged]);
const handleMitreUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setMitreError(null);
setMitreFeedback(null);
setOperationProgress('Importing MITRE Matrix');
try {
const text = await file.text();
const parsed = JSON.parse(text);
const validated = validateMitreMatrixData(parsed);
saveStoredMitreMatrix(validated);
setMitreStatus(validated);
setMitreFeedback(`MITRE Matrix version ${validated.version} (${validated.techniques.length} techniques, ${validated.tacticOrder.length} tactics) updated successfully.`);
globalThis.dispatchEvent(new CustomEvent('assessor:mitre-matrix-changed'));
} catch (err) {
setMitreError(err instanceof Error ? err.message : 'Error loading the MITRE Matrix JSON.');
} finally {
setOperationProgress(undefined);
event.target.value = '';
}
};
const handleMitreReset = () => {
clearStoredMitreMatrix();
setMitreStatus(null);
setMitreFeedback('Default matrix v18.1 restored.');
setMitreError(null);
globalThis.dispatchEvent(new CustomEvent('assessor:mitre-matrix-changed'));
};
const loadProjects = useCallback(async () => {
if (backendStatus !== 'connected') {
setProjects([]);
setProjectData({});
setLoading(false);
return;
}
setLoading(true);
setProjectOpenReadiness({});
setError(null);
try {
projectModelsCache.current.clear();
const list = await assessorApi.listProjects();
const data = await Promise.all(list.map(async project => {
const [files, history, gpoStatus] = await Promise.all([
assessorApi.listProjectImportFiles(project.id),
assessorApi.listProjectAssessmentHistory(project.id),
assessorApi.listProjectGpoBackupStatus(project.id)
]);
const openReadiness = await loadTimeslotOpenReadiness(query => assessorApi.queryUnifiedResultFacets(query), project.id,
groupProjectImportFiles(files, history.assessments, history.scanRuns, gpoStatus.records));
return { projectId: project.id, files, history, gpoRecords: gpoStatus.records, openReadiness };
}));
setProjects(list);
setProjectData(Object.fromEntries(data.map(item => [item.projectId, item.files])));
setProjectHistory(Object.fromEntries(data.map(item => [item.projectId, item.history])));
setProjectGpoData(Object.fromEntries(data.map(item => [item.projectId, item.gpoRecords])));
setProjectOpenReadiness(Object.fromEntries(data.map(item => [item.projectId, item.openReadiness])));
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Projects could not be loaded.');
} finally {
setLoading(false);
}
}, [backendStatus]);
useEffect(() => { void loadProjects(); }, [loadProjects]);
useEffect(() => {
if (backendStatus !== 'connected' || !projects.length) return;
let cancelled = false;
let refreshing = false;
const timer = globalThis.setInterval(async () => {
if (refreshing) return;
refreshing = true;
try {
const entries = await Promise.all(projects.map(async project => {
const response = await assessorApi.listProjectGpoBackupStatus(project.id);
const history = projectHistory[project.id];
const openReadiness = await loadTimeslotOpenReadiness(query => assessorApi.queryUnifiedResultFacets(query), project.id,
groupProjectImportFiles(projectData[project.id] ?? [], history?.assessments, history?.scanRuns, response.records));
return { projectId: project.id, records: response.records, openReadiness };
}));
if (!cancelled) {
setProjectGpoData(Object.fromEntries(entries.map(entry => [entry.projectId, entry.records])));
setProjectOpenReadiness(Object.fromEntries(entries.map(entry => [entry.projectId, entry.openReadiness])));
}
} catch {
if (!cancelled) {
setProjectOpenReadiness({});
setError('GPO processing status could not be refreshed.');
}
} finally {
refreshing = false;
}
}, 5_000);
return () => { cancelled = true; globalThis.clearInterval(timer); };
}, [backendStatus, projects, projectData, projectHistory]);
useEffect(() => {
if (!processingGpoJob) return;
const record = projectGpoData[processingGpoJob.projectId]?.find(item =>
item.artifactId === processingGpoJob.artifactId && item.jobId === processingGpoJob.jobId);
if (record && !['queued', 'running'].includes(record.state)) {
setProcessingGpoJob(undefined);
setOperationProgress(undefined);
}
}, [processingGpoJob, projectGpoData]);
useEffect(() => {
const refresh = () => { void loadProjects(); };
globalThis.addEventListener('assessor:datahub-changed', refresh);
globalThis.addEventListener('assessor:gpo-changed', refresh);
return () => {
globalThis.removeEventListener('assessor:datahub-changed', refresh);
globalThis.removeEventListener('assessor:gpo-changed', refresh);
};
}, [loadProjects]);
const groupsForProject = useCallback((projectId: string): ScanImportGroup[] => {
const history = projectHistory[projectId];
return groupProjectImportFiles(
projectData[projectId] ?? [],
history?.assessments,
history?.scanRuns,
projectGpoData[projectId] ?? []
);
}, [projectData, projectGpoData, projectHistory]);
useEffect(() => {
if (loading || !projects.length) return;
setScanSelection(current => {
if (current) {
const available = new Set(groupsForProject(current.projectId).map(group => group.id));
if (projects.some(project => project.id === current.projectId)
&& current.slots.length <= 1
&& current.slots.every(slot => available.has(slot.id))) return current;
}
const preferred = projects.find(project => project.id === activeProjectId);
if (!preferred) return null;
const latest = groupsForProject(preferred.id).find(group => group.scanRunIds.length);
return {
projectId: preferred.id,
slots: latest ? [{ id: latest.id, runIds: latest.scanRunIds, effectiveAt: latest.fileDate }] : []
};
});
}, [activeProjectId, groupsForProject, loading, projects]);
const loadProjectModels = useCallback(async (projectId: string): Promise<CanonicalAssessmentModel[]> => {
const cached = projectModelsCache.current.get(projectId);
if (cached) return cached;
const assessments = await assessorApi.listProjectAssessments(projectId);
const models = await Promise.all(assessments.map(assessment => assessorApi.loadAssessmentModel(assessment.id)));
projectModelsCache.current.set(projectId, models);
return models;
}, []);
useEffect(() => {
const selectedTimeslot = scanSelection?.slots[scanSelection.slots.length - 1];
if (!selectedTimeslot) {
setTimeslotMetrics(null);
setTimeslotMetricsError(null);
setTimeslotMetricsBusy(false);
return;
}
let cancelled = false;
setTimeslotMetricsBusy(true);
setTimeslotMetricsError(null);
void loadProjectModels(scanSelection.projectId)
.then(models => {
if (!cancelled) setTimeslotMetrics(summarizeTimeslotResults(models, selectedTimeslot.runIds));
})
.catch(reason => {
if (cancelled) return;
setTimeslotMetrics(null);
setTimeslotMetricsError(reason instanceof Error ? reason.message : 'The selected timeslot status could not be loaded.');
})
.finally(() => { if (!cancelled) setTimeslotMetricsBusy(false); });
return () => { cancelled = true; };
}, [loadProjectModels, projectHistory, scanSelection]);
const selectProject = useCallback((projectId: string) => {
const latest = groupsForProject(projectId).find(group => group.scanRunIds.length);
setScanSelection({
projectId,
slots: latest ? [{ id: latest.id, runIds: latest.scanRunIds, effectiveAt: latest.fileDate }] : []
});
}, [groupsForProject]);
const editedGroups = useMemo(() => form?.project ? groupsForProject(form.project.id) : [], [form?.project, groupsForProject]);
const editedReports = useMemo(() => form?.project
? groupScanReports(projectData[form.project.id] ?? [], projectHistory[form.project.id]?.scanRuns)
: [], [form?.project, projectData, projectHistory]);
useEffect(() => {
const projectId = form?.project?.id;
if (!projectId || editedReports.length === 0) {
setScanReadiness({});
return;
}
let cancelled = false;
setScanReadiness(Object.fromEntries(editedReports.map(report => [report.scanRunId, 'checking'])));
void loadProjectModels(projectId).then(models => {
if (cancelled) return;
setScanReadiness(Object.fromEntries(editedReports.map(report => [
report.scanRunId,
datahubScanReadiness(models, report.scanRunId, report.tool.id)
])));
}).catch(() => {
if (!cancelled) setScanReadiness(Object.fromEntries(editedReports.map(report => [report.scanRunId, 'unavailable'])));
});
return () => { cancelled = true; };
}, [editedReports, form?.project?.id, loadProjectModels]);
const selectedTimeslots = useMemo(() => editedGroups.filter(group => selectedTimeslotIds.includes(group.id)), [editedGroups, selectedTimeslotIds]);
const selectedSources = useMemo(() => comparableTimeslotSources(
selectedTimeslots.map(group => ({
id: group.id,
runIds: group.scanRunIds.filter(runId => !excludedScanRunIds.includes(runId) && isDatahubScanViewable(scanReadiness[runId])),
hasGpo: group.gpoBackups.length > 0
})),
form?.project ? projectHistory[form.project.id]?.scanRuns ?? [] : []
), [excludedScanRunIds, form?.project, projectHistory, scanReadiness, selectedTimeslots]);
const openCreate = () => {
setError(null);
setSaveFeedback(null);
setMasterDataEditing(true);
setExcludedScanRunIds([]);
setExpandedTimeslotIds([]);
setDetailSection('client-information');
setForm({ mode: 'create', name: '', description: '', consultant: '' });
};
const openEdit = (project: AssessmentProject) => {
const groups = groupsForProject(project.id);
const latestTimeslot = groups[0];
const files = projectData[project.id] ?? [];
const affectedTimeslotIds = groups
.filter(group => summarizeScanProcessingReadiness(files.filter(file => group.scanRunIds.includes(file.scanRunId))).state !== 'ready'
|| group.gpoBackups.some(record => record.state === 'dead-letter' || record.state === 'recovery-required'))
.map(group => group.id);
setError(null);
setSaveFeedback(null);
setMasterDataEditing(false);
setSelectedTimeslotIds(latestTimeslot ? [latestTimeslot.id] : []);
setExcludedScanRunIds([]);
setExpandedTimeslotIds(affectedTimeslotIds);
setTimeslotDialogOpen(false);
setTimeslotDeleteTarget(null);
setDetailSection('client-information');
setForm({ mode: 'edit', project, name: project.name, description: project.description ?? '', consultant: projectConsultant(project) });
};
const updateForm = (patch: Partial<ProjectFormState>) => {
setSaveFeedback(null);
setForm(current => current ? { ...current, ...patch } : current);
};
const toggleTimeslot = (timeslotId: string, checked: boolean) => {
const group = editedGroups.find(candidate => candidate.id === timeslotId);
setSelectedTimeslotIds(current => checked
? current.includes(timeslotId) ? current : [...current, timeslotId]
: current.filter(id => id !== timeslotId));
if (group) setExcludedScanRunIds(current => current.filter(runId => !group.scanRunIds.includes(runId)));
setError(null);
};
const toggleScanReport = (timeslotId: string, scanRunId: string, checked: boolean) => {
setExcludedScanRunIds(current => checked
? current.filter(id => id !== scanRunId)
: current.includes(scanRunId) ? current : [...current, scanRunId]);
if (checked) {
setSelectedTimeslotIds(current => current.includes(timeslotId) ? current : [...current, timeslotId]);
setExpandedTimeslotIds(current => current.includes(timeslotId) ? current : [...current, timeslotId]);
}
setError(null);
};
const openSelectedTimeslots = () => {
if (!form?.project || !selectedTimeslots.length) return;
const runIds = selectedTimeslots.length === 1
? selectedTimeslots[0].scanRunIds.filter(runId => !excludedScanRunIds.includes(runId) && isDatahubScanViewable(scanReadiness[runId]))
: selectedSources.runIds;
if (!runIds.length) {
if (selectedTimeslots.length === 1 && projectOpenReadiness[form.project.id]?.[selectedTimeslots[0].id]?.hasPolicyResults) {
onProjectSelected(form.project, [], selectedTimeslots[0].assessmentIds);
return;
}
setError('The selected timeslots do not contain a common comparable scanner source.');
return;
}
onProjectSelected(form.project, runIds);
};
const createTimeslot = async (event: FormEvent) => {
event.preventDefault();
if (!form?.project || !timeslotDate) return;
const timeslotId = `timeslot-${timeslotDate}`;
if (editedGroups.some(group => group.id === timeslotId)) {
setError('A timeslot already exists for this date.');
return;
}
setBusy(true);
setError(null);
try {
const now = new Date().toISOString();
const effectiveAt = `${timeslotDate}T12:00:00.000Z`;
const assessment: Assessment = {
id: stableAssessmentId(form.project.id, effectiveAt),
projectId: form.project.id,
name: `Timeslot ${formatTenantDate(effectiveAt)}`,
effectiveAt,
createdAt: now,
updatedAt: now
};
await assessorApi.saveAssessment(assessment);
await loadProjects();
setSelectedTimeslotIds([timeslotId]);
setExcludedScanRunIds([]);
setExpandedTimeslotIds([timeslotId]);
setTimeslotDialogOpen(false);
setSaveFeedback({ tone: 'success', message: 'Timeslot created and selected.' });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'The timeslot could not be created.');
} finally {
setBusy(false);
}
};
const saveProject = async (event: FormEvent) => {
event.preventDefault();
if (!form?.name.trim() || (form.mode === 'edit' && !masterDataEditing)) return;
setBusy(true);
setError(null);
setSaveFeedback(null);
try {
const now = new Date().toISOString();
const project: AssessmentProject = form.project ? {
...form.project,
name: form.name.trim(),
description: form.description.trim(),
updatedAt: now,
attributes: { ...form.project.attributes, consultant: form.consultant.trim() }
} : {
id: stableProjectId(form.name, Date.now(), Date.now()),
name: form.name.trim(),
description: form.description.trim(),
createdAt: now,
updatedAt: now,
attributes: { consultant: form.consultant.trim() }
};
const response = await assessorApi.saveProject(project);
const confirmed = await assessorApi.getProject(response.project.id);
const persisted = confirmed.updatedAt === project.updatedAt
&& confirmed.name === project.name
&& (confirmed.description ?? '') === (project.description ?? '')
&& projectConsultant(confirmed) === projectConsultant(project);
if (!persisted) throw new Error('The database did not confirm the saved state.');
await loadProjects();
setForm({ mode: 'edit', project: confirmed, name: confirmed.name, description: confirmed.description ?? '', consultant: projectConsultant(confirmed) });
setMasterDataEditing(false);
setSaveFeedback({ tone: 'success', message: 'Saved to the database.' });
} catch (reason) {
setSaveFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'Saving to the database failed.' });
} finally {
setBusy(false);
}
};
const deleteProject = async () => {
if (!deleteTarget) return;
setBusy(true);
setError(null);
try {
await assessorApi.deleteProject(deleteTarget.id);
if (activeProjectId === deleteTarget.id) onProjectSelected(undefined);
if (form?.project?.id === deleteTarget.id) {
setMasterDataEditing(false);
setForm(null);
}
setDeleteTarget(null);
await loadProjects();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Project could not be deleted.');
} finally {
setBusy(false);
}
};
const deleteScanRun = async () => {
const projectId = form?.project?.id;
if (!projectId || !scanDeleteTarget) return;
setBusy(true);
setError(null);
setSaveFeedback(null);
try {
const result = await assessorApi.deleteScanRun(projectId, scanDeleteTarget.scanRunId);
if (!result.deleted) throw new Error('The scan report was no longer present in the database.');
setExcludedScanRunIds(current => current.filter(id => id !== scanDeleteTarget.scanRunId));
setScanDeleteTarget(null);
await loadProjects();
if (activeProjectId === projectId) await onProjectDataChanged?.(projectId);
globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } }));
setSaveFeedback({ tone: 'success', message: 'Scan report and its records were deleted from the database.' });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Scan report could not be deleted.');
} finally {
setBusy(false);
}
};
const adoptReplacement = async (replacedReport: ScanImportGroup, replacementReport: ScanImportGroup) => {
const projectId = form?.project?.id;
if (!projectId) return;
setBusy(true);
setError(null);
setSaveFeedback(null);
try {
await assessorApi.adoptScanRunReplacement(projectId, replacedReport.scanRunId, replacementReport.scanRunId);
await loadProjects();
if (activeProjectId === projectId) await onProjectDataChanged?.(projectId);
globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } }));
setSaveFeedback({ tone: 'success', message: `${replacementReport.tool.name} was adopted as the replacement scan. The previous scan remains auditably retained.` });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'The imported scan could not be adopted as the replacement.');
} finally {
setBusy(false);
}
};
const deleteTimeslot = async () => {
const projectId = form?.project?.id;
if (!projectId || !timeslotDeleteTarget) return;
if (!timeslotDeleteTarget.assessmentIds.length) {
setTimeslotDeleteTarget(null);
setError('This timeslot has no persisted assessment and cannot be deleted safely.');
return;
}
setBusy(true);
setError(null);
setSaveFeedback(null);
try {
for (const assessmentId of timeslotDeleteTarget.assessmentIds) {
const result = await assessorApi.deleteAssessment(projectId, assessmentId);
if (!result.deleted) throw new Error('The timeslot was no longer present in the database.');
}
const deletedId = timeslotDeleteTarget.id;
setSelectedTimeslotIds(current => current.filter(id => id !== deletedId));
setExcludedScanRunIds(current => current.filter(runId => !timeslotDeleteTarget.scanRunIds.includes(runId)));
setExpandedTimeslotIds(current => current.filter(id => id !== deletedId));
setTimeslotDeleteTarget(null);
await loadProjects();
if (activeProjectId === projectId) await onProjectDataChanged?.(projectId);
globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } }));
setSaveFeedback({ tone: 'success', message: 'Timeslot and its assigned data were deleted from the database.' });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Timeslot could not be deleted.');
} finally {
setBusy(false);
}
};
const retryGpoBackup = async (record: GpoBackupStatusRecord) => {
const projectId = form?.project?.id;
if (!projectId) return;
setBusy(true);
setOperationProgress('Starting GPO processing');
setError(null);
setSaveFeedback(null);
try {
const result = await assessorApi.retryGpoBackup(projectId, record.artifactId);
setProcessingGpoJob({ projectId, artifactId: result.artifactId, jobId: result.jobId });
setOperationProgress('Processing GPO backup');
await loadProjects();
setSaveFeedback({ tone: 'success', message: 'The GPO analysis job was requeued.' });
} catch (reason) {
setOperationProgress(undefined);
setError(reason instanceof Error ? reason.message : 'The GPO analysis job could not be restarted.');
} finally {
setBusy(false);
}
};
const deleteGpoBackup = async () => {
const projectId = form?.project?.id;
if (!projectId || !gpoDeleteTarget) return;
setBusy(true);
setError(null);
setSaveFeedback(null);
try {
await assessorApi.deleteGpoBackup(projectId, gpoDeleteTarget.artifactId);
setGpoDeleteTarget(null);
await loadProjects();
if (activeProjectId === projectId) await onProjectDataChanged?.(projectId);
globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } }));
setSaveFeedback({ tone: 'success', message: 'GPO backup and its derived records were deleted from the database.' });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'The GPO backup could not be deleted.');
} finally {
setBusy(false);
}
};
if (backendStatus !== 'connected') {
return <div className="datahub-page">
{/* Kein Eyebrow, kein Inhaltstitel: den Seitennamen traegt die Topbar
(siehe Work als visuelle Referenz). Es bleibt die Einordnungszeile. */}
<section className="datahub-hero"><div><p>Datahub is the control center for clients, scans, and findings.</p></div></section>
<section className="datahub-offline" role="status"><div className="datahub-offline-icon" aria-hidden="true">!</div><div><h3>{backendStatus === 'checking' ? 'Checking server connection' : 'Backend is unreachable'}</h3><p>Import and project management will be available again once the local API is connected.</p></div></section>
</div>;
}
const editedProject = form?.project;
const singleTimeslotRunIds = selectedTimeslots.length === 1
? selectedTimeslots[0].scanRunIds.filter(runId => !excludedScanRunIds.includes(runId) && isDatahubScanViewable(scanReadiness[runId]))
: [];
const openDisabled = !selectedTimeslots.length
|| (selectedTimeslots.length === 1
? !projectOpenReadiness[form?.project?.id ?? '']?.[selectedTimeslots[0].id]?.canOpen
|| (!singleTimeslotRunIds.length && !projectOpenReadiness[form?.project?.id ?? '']?.[selectedTimeslots[0].id]?.hasPolicyResults)
: !selectedSources.runIds.length);
const filesSection = editedProject && <section className="tenant-timeslots" aria-label="Timeslots">
<div className="tenant-timeslots-header">
<h2 className="tenant-timeslots-heading">Timeslots</h2>
<button className="btn tenant-timeslot-add" type="button" aria-label="Create timeslot" title="Create Timeslot" onClick={() => { setTimeslotDate(new Date().toLocaleDateString('sv-SE')); setTimeslotDialogOpen(true); }}><span aria-hidden="true">+</span> Timeslot</button>
</div>
<div className="tenant-timeslot-list">
{loading ? <PanelLoadingState message="Timeslots are loading …" compact/> : editedGroups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No timeslots yet</h3><p>Create the first dated assessment timeslot.</p></div> : editedGroups.map(group => {
const reports = editedReports.filter(report => group.scanRunIds.includes(report.scanRunId));
const processingByRun = new Map(reports.map(report => [report.scanRunId, summarizeScanProcessingReadiness(report.files)]));
const affectedReports = reports.filter(report => processingByRun.get(report.scanRunId)?.state !== 'ready');
const hasProcessingError = affectedReports.some(report => processingByRun.get(report.scanRunId)?.state === 'error');
const replacementByRun = new Map(affectedReports.flatMap(affected => {
const candidate = reports
.filter(report => report.scanRunId !== affected.scanRunId
&& report.tool.id === affected.tool.id
&& compareReimportSourceHashes(affected.files.map(file => file.sha256), report.files.map(file => file.sha256)) === 'match'
&& Date.parse(report.importedAt) > Date.parse(affected.importedAt)
&& processingByRun.get(report.scanRunId)?.state === 'ready'
&& scanReadiness[report.scanRunId] === 'recognized')
.sort((left, right) => Date.parse(right.importedAt) - Date.parse(left.importedAt))[0];
return candidate ? [[affected.scanRunId, candidate] as const] : [];
}));
const viewableReports = reports.filter(report => isDatahubScanViewable(scanReadiness[report.scanRunId]));
const selected = selectedTimeslotIds.includes(group.id);
const selectedReportCount = selected ? viewableReports.filter(report => !excludedScanRunIds.includes(report.scanRunId)).length : 0;
const timeslotChecked = selected && (viewableReports.length === 0 || selectedReportCount === viewableReports.length);
const timeslotIndeterminate = selected && viewableReports.length > 0 && selectedReportCount < viewableReports.length;
return <details
className={`tenant-files tenant-scan-history tenant-timeslot${selected ? ' is-selected' : ''}`}
key={group.id}
open={expandedTimeslotIds.includes(group.id)}
onToggle={event => {
const open = event.currentTarget.open;
setExpandedTimeslotIds(current => open
? current.includes(group.id) ? current : [...current, group.id]
: current.filter(id => id !== group.id));
}}
>
<summary className="tenant-section-bar">
<span className="tenant-timeslot-title">
<input ref={element => { if (element) element.indeterminate = timeslotIndeterminate; }} type="checkbox" checked={timeslotChecked} aria-label={`Select timeslot ${formatTenantDate(group.fileDate)}`} onClick={event => event.stopPropagation()} onChange={event => toggleTimeslot(group.id, event.target.checked)}/>
<strong>{formatTenantDate(group.fileDate)}</strong>
</span>
<span className="tenant-history-meta"><b>{(reports.length + group.gpoBackups.length).toLocaleString('de-DE')}</b><TriangleIcon direction="right"/></span>
</summary>
{reports.length === 0 && group.gpoBackups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No sources</h3><p>This timeslot does not contain a scan report or GPO backup yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Assessment sources from ${formatTenantDate(group.fileDate)}`}>
<div className="tenant-report-row tenant-report-head" role="row"><span aria-hidden="true"/><span>Source</span><span>Imported</span><span>Status</span><span>Actions</span></div>
{reports.map(report => {
const readiness = scanReadiness[report.scanRunId] ?? 'checking';
const processing = processingByRun.get(report.scanRunId)!;
const viewable = isDatahubScanViewable(readiness);
const statusLabel = processing.state === 'update' ? 'Reimport recommended'
: processing.state === 'error' ? 'Processing error'
: readiness === 'recognized' ? 'Ready'
: readiness === 'generic' ? 'Generic'
: readiness === 'not-viewable' ? 'Not viewable'
: readiness === 'unavailable' ? 'Unavailable'
: 'Checking';
const statusTitle = processing.state !== 'ready' ? processing.explanation
: readiness === 'recognized' ? 'Tool recognized, Work records available, and processing contract ready.'
: readiness === 'generic' ? 'Work records are available, but no specific scanner profile was recognized.'
: readiness === 'not-viewable' ? 'The upload is stored, but it produced no records that Work can display.'
: readiness === 'unavailable' ? 'Display readiness could not be checked.'
: 'Display readiness is being checked.';
return <div className="tenant-report-row" role="row" key={report.id}>
<input type="checkbox" disabled={!viewable} checked={viewable && selected && !excludedScanRunIds.includes(report.scanRunId)} aria-label={`Select ${report.tool.name} scan report`} onChange={event => toggleScanReport(group.id, report.scanRunId, event.target.checked)}/>
<span className="tenant-report-copy"><b>{report.tool.name}</b><small>{report.fileCount.toLocaleString('de-DE')} {report.fileCount === 1 ? 'file' : 'files'} · {report.formats.join(', ') || '—'}</small></span>
<span className="tenant-report-imported">{formatHistoryDate(report.importedAt)}</span>
<b className="tenant-report-status" data-status={processing.state === 'update' ? 'reimport-recommended' : processing.state === 'error' ? 'processing-error' : readiness} title={statusTitle}>{statusLabel}</b>
<button className="btn tenant-scan-delete" type="button" title={`Delete ${report.tool.name} scan report`} aria-label={`Delete ${report.tool.name} scan report`} onClick={() => setScanDeleteTarget(report)}>Delete</button>
</div>;
})}
{group.gpoBackups.map(record => <div className="tenant-report-row tenant-report-gpo" role="row" key={record.artifactId}>
<span className="tenant-gpo-source-marker" aria-label="Policy analysis source">G</span>
<span className="tenant-report-copy"><b>GPO Backup</b><small>{record.fileName} · {record.gpoCount.toLocaleString('de-DE')} GPO{record.gpoCount === 1 ? '' : 's'}</small></span>
<span className="tenant-report-imported">{formatHistoryDate(record.submittedAt)}</span>
<b className="tenant-report-status" data-status={record.state}>{gpoStateLabels[record.state]}</b>
<span className="tenant-gpo-row-actions">
{record.state === 'dead-letter'
&& <button className="btn compact" type="button" disabled={busy} onClick={() => void retryGpoBackup(record)}>Retry</button>}
<button className="btn compact danger" type="button" disabled={busy || ['queued', 'running'].includes(record.state)} onClick={() => setGpoDeleteTarget(record)}>Delete</button>
</span>
</div>)}
</div>}
{affectedReports.length > 0 && <section className="tenant-processing-alert" data-status={hasProcessingError ? 'error' : 'update'} aria-label={hasProcessingError ? 'ASSESSOR processing errors require attention' : 'ASSESSOR processing profile updates available'}>
<header><strong>{hasProcessingError ? 'ASSESSOR processing issue' : 'New ASSESSOR processing profile available'}</strong><span>{hasProcessingError
? 'At least one stored processing contract cannot be assessed safely. Review the affected scan and reimport its unchanged original file.'
: 'ASSESSOR has updated its internal scanner-processing profile. Reimport the unchanged original scanner file to apply it; the existing scan remains active until replacement succeeds.'}</span></header>
{affectedReports.map(report => {
const replacement = replacementByRun.get(report.scanRunId);
return <div className="tenant-processing-alert-row" key={report.scanRunId}>
<div><strong>{report.tool.name}</strong><p>{replacement
? 'A newer ready scan of the same tool is present. It can be adopted as the replacement without deleting or uploading it again.'
: processingByRun.get(report.scanRunId)!.explanation}</p></div>
{replacement ? <button className="btn primary" type="button" disabled={busy} onClick={() => void adoptReplacement(report, replacement)}>
Use imported {report.tool.name}
</button> : <FileImport
targetProjectId={editedProject.id}
targetAssessmentId={report.assessmentIds[0]}
replacesScanRunId={report.scanRunId}
label={`Reimport ${report.tool.name}`}
/>}
</div>;
})}
</section>}
<div className="tenant-timeslot-actions">
<div className="tenant-timeslot-add-heading">
<h4>Add Entry</h4>
</div>
<FileImport targetProjectId={editedProject.id} targetAssessmentId={group.assessmentIds[0]} label="Import Scan"/>
<GpoFolderImport projectId={editedProject.id} assessmentId={group.assessmentIds[0]} label="Import GPO Folder" archiveLabel="Import GPO ZIP" showStatus={false}/>
<div className="tenant-timeslot-delete-action">
<span>Entire timeslot</span>
<button className="btn danger" type="button" onClick={() => setTimeslotDeleteTarget(group)}>Delete timeslot</button>
</div>
</div>
</details>;
})}
</div>
</section>; return <div className={'datahub-page' + (form ? ' tenant-detail-page' : '')}>
{form ? <div className="tenant-detail-layout">
<aside className="tenant-detail-navigation">
<section className="tenant-editor-header">
<button className="tenant-back" type="button" title="Back to client overview" aria-label="Back to client overview" onClick={() => { if (!busy) { setMasterDataEditing(false); setExcludedScanRunIds([]); setDetailSection('client-information'); setForm(null); } }}><TriangleIcon direction="left" /><span>Back</span></button>
{form.project && <button className="btn primary tenant-navigation-open" type="button" disabled={openDisabled} title={!selectedTimeslots.length ? 'Select at least one timeslot' : selectedTimeslots.length > 1 ? 'Open comparable sources from the selected timeslots' : 'Open selected scan reports'} onClick={openSelectedTimeslots}>Open</button>}
<div className="tenant-identity"><h2>{form.mode === 'create' ? 'Create Client' : form.project?.name}</h2>{form.project && <p>{formatTenantDate(form.project.createdAt)}</p>}</div>
</section>
{form.mode === 'edit' && filesSection}
</aside>
<main className="tenant-detail-information">
{detailSection === 'client-information' ? <section className="tenant-information-sheet" aria-label="Client information sheet">
<header className="tenant-information-header">
<div className="tenant-information-title"><strong>Client Information</strong></div>
<div className="tenant-information-actions">{form.project && projectGpoData[form.project.id]?.length > 0 && !masterDataEditing && <button className="btn tenant-show-gpo" type="button" onClick={() => setDetailSection('gpo-analysis')}>GPO Analysis</button>}{form.project && !masterDataEditing && <button className="btn danger tenant-delete-client" type="button" onClick={() => setDeleteTarget(form.project!)}>Delete client</button>}{form.mode === 'edit' && !masterDataEditing && <button className="btn primary tenant-edit-client" type="button" disabled={busy} onClick={() => { setSaveFeedback(null); setMasterDataEditing(true); }}>Edit</button>}{(form.mode === 'create' || masterDataEditing) && <button className="btn primary tenant-save-client" type="submit" form="tenant-editor-form" disabled={busy || !form.name.trim()}>{busy ? 'Saving …' : 'Save'}</button>}</div>
</header>
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => setError(null)}>Close</button></div>}
{saveFeedback && <div className={"tenant-save-feedback " + saveFeedback.tone} role={saveFeedback.tone === "error" ? "alert" : "status"}><span aria-hidden="true">{saveFeedback.tone === "success" ? "✓" : "!"}</span><strong>{saveFeedback.message}</strong></div>}
<form id="tenant-editor-form" className={`tenant-editor-form${masterDataEditing ? ' is-editing' : ''}`} onSubmit={saveProject}>
<label><span className="tenant-field-label">Name{masterDataEditing && <i>Required</i>}</span>{masterDataEditing ? <input className="input" value={form.name} onChange={event => updateForm({ name: event.target.value })} required autoFocus={form.mode === 'create'}/> : <span className="tenant-field-value">{form.name || '—'}</span>}</label>
<label><span className="tenant-field-label">Consultant{masterDataEditing && <i>optional</i>}</span>{masterDataEditing ? <input className="input" value={form.consultant} onChange={event => updateForm({ consultant: event.target.value })} placeholder="Responsible person"/> : <span className="tenant-field-value">{form.consultant || '—'}</span>}</label>
<label className="tenant-editor-description"><span className="tenant-field-label">Remarks{masterDataEditing && <i>optional</i>}</span>{masterDataEditing ? <textarea className="input" value={form.description} onChange={event => updateForm({ description: event.target.value })} rows={10} placeholder="Notes about the client or project"/> : <span className="tenant-field-value tenant-field-prose">{form.description || '—'}</span>}</label>
</form>
</section> : form.project ? <section className="tenant-information-sheet tenant-policy-information" aria-label="GPO policy analysis">
<header className="tenant-information-header">
<div className="tenant-information-title"><strong>GPO Analysis</strong></div>
<div className="tenant-information-actions"><button className="btn" type="button" onClick={() => setDetailSection('client-information')}>Client Information</button></div>
</header>
<PolicyAnalysisPanel projectId={form.project.id}/>
</section> : null}
</main>
</div> : <>
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void loadProjects()}>Neu laden</button></div>}
<DatahubHistoryOverview
projects={projects}
projectData={projectData}
projectHistory={projectHistory}
projectGpoData={projectGpoData}
projectOpenReadiness={projectOpenReadiness}
selection={scanSelection}
timeslotMetrics={timeslotMetrics}
loading={loading}
comparisonBusy={timeslotMetricsBusy}
comparisonError={timeslotMetricsError}
onCreate={openCreate}
onShow={openEdit}
onOpen={(project, scanRunIds, assessmentIds) => onProjectSelected(project, scanRunIds, assessmentIds)}
onSelectProject={selectProject}
/>
<div className={`datahub-reference-row${SHOW_MITRE_MATRIX_LIBRARY ? '' : ' is-mitre-hidden'}`}>
<MicrosoftBaselineLibrary/>
{SHOW_MITRE_MATRIX_LIBRARY && <MitreMatrixLibrary
status={mitreStatus}
feedback={mitreFeedback}
error={mitreError}
onUpload={event => void handleMitreUpload(event)}
onReset={handleMitreReset}
/>}
</div>
</>}
{scanDeleteTarget && <Dialog title="Delete scan report from the database?" onClose={() => !busy && setScanDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{scanDeleteTarget.tool.name}</strong><p>{new Date(scanDeleteTarget.fileDate).toLocaleString('en-US')} · {scanDeleteTarget.fileCount} file{scanDeleteTarget.fileCount === 1 ? '' : 's'}</p><p>The scan report, its findings, evidence, and scan-related mappings will be permanently deleted. Other scan reports for this client remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setScanDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteScanRun()}>{busy ? 'Deleting …' : 'Yes, delete scan report'}</button></footer></div></Dialog>}
{gpoDeleteTarget && <Dialog title="Delete GPO backup from the database?" onClose={() => !busy && setGpoDeleteTarget(null)}><div className="datahub-delete-warning"><strong>GPO Backup</strong><p>{gpoDeleteTarget.fileName} · {gpoDeleteTarget.gpoCount} GPO{gpoDeleteTarget.gpoCount === 1 ? '' : 's'}</p><p>The GPO backup, its analysis, evaluations, and derived findings will be permanently deleted. The timeslot and all other scan and GPO entries remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setGpoDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteGpoBackup()}>{busy ? 'Deleting …' : 'Yes, delete GPO backup'}</button></footer></div></Dialog>}
{timeslotDeleteTarget && <Dialog title="Delete timeslot from the database?" onClose={() => !busy && setTimeslotDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{formatTenantDate(timeslotDeleteTarget.fileDate)}</strong><p>{timeslotDeleteTarget.sourceCount} source{timeslotDeleteTarget.sourceCount === 1 ? '' : 's'} · {timeslotDeleteTarget.fileCount + timeslotDeleteTarget.gpoBackups.length} assigned file{timeslotDeleteTarget.fileCount + timeslotDeleteTarget.gpoBackups.length === 1 ? '' : 's'}</p><p>The timeslot, its scan reports, GPO backup data, findings, evidence, and related mappings will be permanently deleted. The client and all other timeslots remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setTimeslotDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteTimeslot()}>{busy ? 'Deleting …' : 'Yes, delete timeslot'}</button></footer></div></Dialog>}
{timeslotDialogOpen && <Dialog title="Create Timeslot" onClose={() => !busy && setTimeslotDialogOpen(false)}><form className="timeslot-dialog-form" onSubmit={createTimeslot}><label>Date<input className="input" type="date" value={timeslotDate} required onChange={event => setTimeslotDate(event.target.value)}/></label><p>Imports added afterwards are assigned to this dated assessment timeslot.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setTimeslotDialogOpen(false)}>Cancel</button><button className="btn primary" type="submit" disabled={busy || !timeslotDate}>{busy ? 'Creating …' : 'Create'}</button></footer></form></Dialog>}
{deleteTarget && <Dialog title="Permanently delete client?" onClose={() => !busy && setDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{deleteTarget.name}</strong><p>All assessments, scan reports, findings, reviews, and mappings for this client will be permanently deleted from the database. This action cannot be undone.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteProject()}>{busy ? 'Deleting …' : 'Yes, delete permanently'}</button></footer></div></Dialog>}
<OperationProgressDialog label={operationProgress}/>
</div>;
}