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

1005 lines
59 KiB
TypeScript

import { Fragment, 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 { defaultSecurityScoringPolicy } from '../../domain/security-scoring';
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 { GpoStatusRow } from './GpoStatusRow';
import { PolicyAnalysisPanel } from './PolicyAnalysisPanel';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
import { IsoCorpusLibrary } from './IsoCorpusLibrary';
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel';
import { activeMitreMatrix, clearStoredMitreMatrix, loadStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix, setServerMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
import { downloadBrowserBlob } from '../../platform/browser/browser-download-adapter';
import { TriangleIcon } from '../../components/TriangleIcon';
import { DatahubHistoryOverview, type DatahubTimeslotSelection } from './DatahubHistoryOverview';
import { comparableTimeslotSources, 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';
import { DatahubMetricsCache } from './datahub-metrics-cache';
import { useReferenceRecognitionStatus } from './use-reference-recognition-status';
import { datahubScanLabel } from './datahub-scan-labels';
const assessorApi = new AssessorApiClient();
const metricsCache = new DatahubMetricsCache(scope => assessorApi.getTimeslotResultMetrics(
scope.projectId, scope.scanRunIds, scope.assessmentIds));
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';
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 gpoStatusRevision(records: GpoBackupStatusRecord[]): string {
return JSON.stringify(records.map(record => [
record.artifactId, record.jobId, record.state, record.updatedAt, record.gpoCount, record.notLinkedGpoCount ?? null,
record.evaluationUpdates?.map(update => [update.evaluationRunId, update.previousVersion, update.currentVersion]) ?? []
]).sort((left, right) => String(left[0]).localeCompare(String(right[0]))));
}
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),
name: datahubScanLabel(run?.tool ?? runFiles[0].detectedTool, scanRunId, runFiles) },
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 [referenceRefreshKey, setReferenceRefreshKey] = useState(0);
const { states: projectReferenceReadiness, reportStatus: reportReferenceStatus } = useReferenceRecognitionStatus(
projects, backendStatus === 'connected', referenceRefreshKey);
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 [mitreActiveVersion, setMitreActiveVersion] = useState(() => activeMitreMatrix().version);
const [mitreBusy, setMitreBusy] = useState<'refresh' | 'download' | 'upload' | null>(null);
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 [metricsRevision, setMetricsRevision] = useState<number | 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 [scanLabels, setScanLabels] = useState<Record<string, string>>({});
const [projectOpenReadiness, setProjectOpenReadiness] = useState<Record<string, Record<string, TimeslotOpenReadiness>>>({});
const [detailSection, setDetailSection] = useState<TenantDetailSection>('client-information');
const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>());
const projectLoadGeneration = useRef(0);
const preferredProjectId = useRef(activeProjectId);
preferredProjectId.current = activeProjectId;
useEffect(() => {
onDetailViewChanged?.(form !== null, form?.mode === 'edit' ? form.name.trim() || form.project?.name : undefined);
}, [form, onDetailViewChanged]);
useEffect(() => () => onDetailViewChanged?.(false), [onDetailViewChanged]);
useEffect(() => {
let mounted = true;
void assessorApi.getMitreCatalog().then(state => {
if (!mounted) return;
setServerMitreMatrix(state.catalog);
setMitreActiveVersion(activeMitreMatrix().version);
}).catch(() => undefined);
return () => { mounted = false; };
}, []);
const handleMitreRefresh = async () => {
setMitreBusy('refresh');
setMitreError(null);
setMitreFeedback(null);
try {
const result = await assessorApi.refreshMitreCatalog();
clearStoredMitreMatrix();
setMitreStatus(null);
setServerMitreMatrix(result.catalog);
setMitreActiveVersion(result.catalog.version);
setReferenceRefreshKey(current => current + 1);
setMitreFeedback(result.updated
? `Enterprise ATT&CK v${result.catalog.version} is active in ASSESSOR.`
: `Enterprise ATT&CK v${result.catalog.version} is already current.`);
} catch (error) {
setMitreError(error instanceof Error ? error.message : 'MITRE could not be updated.');
} finally {
setMitreBusy(null);
}
};
const handleMitreDownload = async () => {
setMitreBusy('download');
setMitreError(null);
setMitreFeedback(null);
try {
const source = await assessorApi.downloadLatestMitreSource();
downloadBrowserBlob(source.blob, source.fileName);
setMitreFeedback(`Official MITRE file ${source.fileName} downloaded.`);
} catch (error) {
setMitreError(error instanceof Error ? error.message : 'MITRE could not be downloaded.');
} finally {
setMitreBusy(null);
}
};
const handleMitreUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setMitreError(null);
setMitreFeedback(null);
setMitreBusy('upload');
setOperationProgress('Importing MITRE Matrix');
try {
const text = await file.text();
const parsed = JSON.parse(text);
const validated = validateMitreMatrixData(parsed);
saveStoredMitreMatrix(validated);
setMitreStatus(validated);
setMitreActiveVersion(validated.version);
setMitreFeedback(`MITRE Matrix version ${validated.version} (${validated.techniques.length} techniques, ${validated.tacticOrder.length} tactics) updated successfully.`);
globalThis.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT));
} catch (err) {
setMitreError(err instanceof Error ? err.message : 'Error loading the MITRE Matrix JSON.');
} finally {
setOperationProgress(undefined);
setMitreBusy(null);
event.target.value = '';
}
};
const handleMitreReset = () => {
clearStoredMitreMatrix();
setMitreStatus(null);
setMitreActiveVersion(activeMitreMatrix().version);
setMitreFeedback(`Active catalog v${activeMitreMatrix().version} restored.`);
setMitreError(null);
globalThis.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT));
};
const loadProjects = useCallback(async () => {
const generation = ++projectLoadGeneration.current;
setMetricsRevision(null);
setTimeslotMetrics(null);
if (backendStatus !== 'connected') {
setProjects([]);
setProjectData({});
setLoading(false);
return;
}
setLoading(true);
setProjectOpenReadiness({});
setError(null);
try {
projectModelsCache.current.clear();
const [list, health] = await Promise.all([assessorApi.listProjects(), assessorApi.health()]);
const prioritized = [...list].sort((left, right) => Number(right.id === preferredProjectId.current) - Number(left.id === preferredProjectId.current));
const data = await Promise.all(prioritized.map(async project => {
const [files, history, gpoStatus] = await Promise.all([
assessorApi.listProjectImportFiles(project.id),
assessorApi.listProjectAssessmentHistory(project.id),
assessorApi.listProjectGpoBackupStatus(project.id)
]);
if (generation === projectLoadGeneration.current) {
const latest = groupProjectImportFiles(files, history.assessments, history.scanRuns, gpoStatus.records)
.find(group => group.scanRunIds.length);
if (latest) metricsCache.prefetch({ revision: health.repositoryRevision, projectId: project.id,
scanRunIds: latest.scanRunIds, assessmentIds: latest.assessmentIds });
}
return { projectId: project.id, files, history, gpoRecords: gpoStatus.records };
}));
if (generation !== projectLoadGeneration.current) return;
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])));
setReferenceRefreshKey(current => current + 1);
setMetricsRevision(health.repositoryRevision);
// Open-readiness checks are independent of the six result metrics. Start
// them after the overview is available so they cannot hold back the rings.
globalThis.setTimeout(() => {
if (generation !== projectLoadGeneration.current) return;
void Promise.all(data.map(async item => ({ projectId: item.projectId,
openReadiness: await loadTimeslotOpenReadiness(query => assessorApi.queryUnifiedResultFacets(query), item.projectId,
groupProjectImportFiles(item.files, item.history.assessments, item.history.scanRuns, item.gpoRecords))
}))).then(entries => {
if (generation === projectLoadGeneration.current) setProjectOpenReadiness(Object.fromEntries(entries.map(item => [item.projectId, item.openReadiness])));
});
}, 0);
} catch (reason) {
if (generation === projectLoadGeneration.current) setError(reason instanceof Error ? reason.message : 'Projects could not be loaded.');
} finally {
if (generation === projectLoadGeneration.current) setLoading(false);
}
}, [backendStatus]);
useEffect(() => { void loadProjects(); }, [loadProjects]);
useEffect(() => () => { projectLoadGeneration.current++; }, []);
const pendingGpoProjectIds = projects
.filter(project => projectGpoData[project.id]?.some(record => record.state === 'queued' || record.state === 'running'))
.map(project => project.id);
const pendingGpoProjectKey = pendingGpoProjectIds.join('\u0000');
useEffect(() => {
if (backendStatus !== 'connected' || !pendingGpoProjectKey) return;
const projectIds = pendingGpoProjectKey.split('\u0000');
let cancelled = false;
let refreshing = false;
const timer = globalThis.setInterval(async () => {
if (refreshing) return;
refreshing = true;
try {
const entries = (await Promise.all(projectIds.map(async projectId => {
const response = await assessorApi.listProjectGpoBackupStatus(projectId);
if (gpoStatusRevision(response.records) === gpoStatusRevision(projectGpoData[projectId] ?? [])) return null;
const history = projectHistory[projectId];
const openReadiness = await loadTimeslotOpenReadiness(query => assessorApi.queryUnifiedResultFacets(query), projectId,
groupProjectImportFiles(projectData[projectId] ?? [], history?.assessments, history?.scanRuns, response.records));
return { projectId, records: response.records, openReadiness };
}))).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
if (!cancelled && entries.length) {
metricsCache.invalidate();
void loadProjects();
setProjectGpoData(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.projectId, entry.records])) }));
setProjectOpenReadiness(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.projectId, entry.openReadiness])) }));
}
} catch {
if (!cancelled) {
setError('GPO processing status could not be refreshed.');
}
} finally {
refreshing = false;
}
}, 5_000);
return () => { cancelled = true; globalThis.clearInterval(timer); };
}, [backendStatus, pendingGpoProjectKey, projectData, projectGpoData, projectHistory, loadProjects]);
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 = () => { metricsCache.invalidate(); 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, assessmentIds: latest.assessmentIds, 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 || metricsRevision === null) {
setTimeslotMetrics(null);
setTimeslotMetricsError(null);
setTimeslotMetricsBusy(Boolean(selectedTimeslot));
return;
}
let cancelled = false;
const scope = { revision: metricsRevision, projectId: scanSelection.projectId,
scanRunIds: selectedTimeslot.runIds, assessmentIds: selectedTimeslot.assessmentIds };
const cached = metricsCache.peek(scope);
setTimeslotMetrics(cached ?? null);
setTimeslotMetricsBusy(cached === undefined);
setTimeslotMetricsError(null);
void metricsCache.load(scope)
.then(metrics => {
if (cancelled) return;
setTimeslotMetrics(metrics);
})
.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; };
}, [scanSelection, metricsRevision]);
const selectProject = useCallback((projectId: string) => {
const latest = groupsForProject(projectId).find(group => group.scanRunIds.length);
const cached = latest && metricsRevision !== null ? metricsCache.peek({ revision: metricsRevision, projectId,
scanRunIds: latest.scanRunIds, assessmentIds: latest.assessmentIds }) : undefined;
setTimeslotMetrics(cached ?? null);
setTimeslotMetricsError(null);
setTimeslotMetricsBusy(Boolean(latest) && cached === undefined);
setScanSelection({
projectId,
slots: latest ? [{ id: latest.id, runIds: latest.scanRunIds, assessmentIds: latest.assessmentIds, effectiveAt: latest.fileDate }] : []
});
}, [groupsForProject, metricsRevision]);
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({});
setScanLabels({});
return;
}
let cancelled = false;
setScanLabels({});
setScanReadiness(Object.fromEntries(editedReports.map(report => [report.scanRunId, 'checking'])));
void loadProjectModels(projectId).then(models => {
if (cancelled) return;
setScanLabels(Object.fromEntries(editedReports.map(report => [report.scanRunId,
datahubScanLabel(report.tool, report.scanRunId, report.files, models)])));
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,
[...new Set(selectedTimeslots.flatMap(timeslot => timeslot.assessmentIds))]);
};
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,
scoringPolicy: defaultSecurityScoringPolicy(),
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 {
const adopted = 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 } }));
const transfer = adopted.reimportReviewTransfer;
const preservation = transfer.unresolved > 0
? ` ${transfer.transferred} client records were transferred; ${transfer.unresolved} remain retained on the previous scan and require review.`
: ` ${transfer.transferred} client records were transferred safely.`;
setSaveFeedback({ tone: 'success', message: `${replacementReport.tool.name} was adopted as the replacement scan. The previous scan remains auditably retained.${preservation}` });
} 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 reevaluateGpoBackup = async (record: GpoBackupStatusRecord) => {
const projectId = form?.project?.id;
if (!projectId || !record.evaluationUpdates?.length) return;
setBusy(true);
setOperationProgress('Re-evaluating GPO baseline comparisons');
setError(null);
setSaveFeedback(null);
let failure: string | undefined;
try {
for (const update of record.evaluationUpdates) {
await assessorApi.evaluatePolicy(projectId, {
aggregationRunId: update.aggregationRunId, baselineAnalysisIds: update.baselineAnalysisIds
});
}
} catch (reason) {
failure = reason instanceof Error ? reason.message : 'GPO re-evaluation failed. Review the processing controls in GPO Analysis. Previous results remain retained.';
} finally {
await loadProjects();
if (failure) setError(failure);
else setSaveFeedback({ tone: 'success', message: 'GPO re-evaluation completed. Previous runs remain retained as history. Reopen the timeslot to load the current results.' });
setOperationProgress(undefined);
setBusy(false);
}
};
const reprocessGpoBackup = async (record: GpoBackupStatusRecord) => {
const projectId = form?.project?.id;
if (!projectId || !record.analysisUpdate) return;
setBusy(true);
setOperationProgress('Reprocessing the stored GPO source');
setError(null);
setSaveFeedback(null);
try {
const result = await assessorApi.reprocessGpoBackup(projectId, record.artifactId,
record.analysisUpdate.expectedLatestProcessingRunId);
setProcessingGpoJob({ projectId, artifactId: result.artifactId, jobId: result.jobId });
setOperationProgress('Processing GPO backup');
await loadProjects();
setSaveFeedback({ tone: 'success', message: 'GPO reprocessing was queued. Previous runs remain retained as history; re-evaluate once processing completes.' });
} catch (reason) {
setOperationProgress(undefined);
setError(reason instanceof Error ? reason.message
: 'GPO reprocessing could not be started. Previous results remain retained.');
} 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>
<BackendConnectionDialog
backendStatus={backendStatus}
message="Import and project management will be available again once the local API is connected."
/>
</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 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(sourceReport => {
const report = scanLabels[sourceReport.scanRunId]
? { ...sourceReport, tool: { ...sourceReport.tool, name: scanLabels[sourceReport.scanRunId] } }
: sourceReport;
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 === 'review' ? 'Review required'
: 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.';
const replacement = replacementByRun.get(report.scanRunId);
const processingHeading = processing.state === 'error' ? 'ASSESSOR processing issue'
: processing.state === 'review' ? 'Review required'
: 'New ASSESSOR processing profile available';
return <Fragment key={report.id}>
<div className="tenant-report-row" role="row">
<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 === 'review' ? 'review-required' : 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>
{processing.state !== 'ready' && <section className="tenant-processing-alert tenant-processing-alert-inline"
data-status={processing.state} aria-label={`${report.tool.name}: ${processingHeading}`}>
<div><strong>{processingHeading}</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.'
: processing.explanation}</p></div>
{replacement ? <button className="btn primary" type="button" disabled={busy} onClick={() => void adoptReplacement(report, replacement)}>
Use imported {report.tool.name}
</button> : processing.state !== 'review' && <FileImport
targetProjectId={editedProject.id}
targetAssessmentId={report.assessmentIds[0]}
replacesScanRunId={report.scanRunId}
label={`Reimport ${report.tool.name}`}
/>}
</section>}
</Fragment>;
})}
{group.gpoBackups.map(record => <GpoStatusRow key={record.artifactId} record={record} projectId={form.project!.id}
imported={formatHistoryDate(record.submittedAt)} busy={busy}
onEvaluate={() => void reevaluateGpoBackup(record)} onReprocess={() => void reprocessGpoBackup(record)}
onRetry={() => void retryGpoBackup(record)}
onDelete={() => setGpoDeleteTarget(record)} />)}
</div>}
<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' && <div className="tenant-source-navigation">
{form.project && <ReferenceRecognitionPanel key={form.project.id} projectId={form.project.id}
onStatusChanged={reportReferenceStatus}
onChanged={() => onProjectDataChanged?.(form.project!.id)}/>}
{filesSection}
</div>}
</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}
projectReferenceReadiness={projectReferenceReadiness}
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">
<MicrosoftBaselineLibrary/>
<IsoCorpusLibrary/>
<MitreMatrixLibrary
status={mitreStatus}
activeVersion={mitreActiveVersion}
busy={mitreBusy}
feedback={mitreFeedback}
error={mitreError}
onRefresh={() => void handleMitreRefresh()}
onDownload={() => void handleMitreDownload()}
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>;
}