1029 lines
61 KiB
TypeScript
1029 lines
61 KiB
TypeScript
import { beginActivity, showActivityFrame } from '../../platform/activity-store';
|
|
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 { ClientInformationChangeDialog, type ClientInformationChange } from './ClientInformationChangeDialog';
|
|
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 '../../compliance/ui/IsoCorpusLibrary';
|
|
import { BsiCatalogLibrary } from '../../compliance/ui/BsiCatalogLibrary';
|
|
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 [pendingClientChange, setPendingClientChange] = useState<{ form: ProjectFormState; changes: ClientInformationChange[] } | null>(null);
|
|
const saveInFlight = useRef(false);
|
|
const cancelClientChange = useCallback(() => setPendingClientChange(null), []);
|
|
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;
|
|
const activity = beginActivity('Core Engine', 'Reading and validating MITRE catalog file');
|
|
setMitreError(null);
|
|
setMitreFeedback(null);
|
|
setMitreBusy('upload');
|
|
setOperationProgress('Importing MITRE Matrix');
|
|
try {
|
|
await showActivityFrame();
|
|
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) {
|
|
activity.fail();
|
|
setMitreError(err instanceof Error ? err.message : 'Error loading the MITRE Matrix JSON.');
|
|
} finally {
|
|
activity.complete();
|
|
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 (busy || pendingClientChange || !form?.name.trim() || (form.mode === 'edit' && !masterDataEditing)) return;
|
|
const changes: ClientInformationChange[] = form.project ? [
|
|
{ label: 'Name' as const, previous: form.project.name, next: form.name.trim() },
|
|
{ label: 'Consultant' as const, previous: projectConsultant(form.project), next: form.consultant.trim() }
|
|
].filter(change => change.previous !== change.next) : [];
|
|
if (changes.length) { setPendingClientChange({ form: { ...form }, changes }); return; }
|
|
await persistProject(form);
|
|
};
|
|
const persistProject = async (form: ProjectFormState) => {
|
|
if (saveInFlight.current) return;
|
|
saveInFlight.current = true;
|
|
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);
|
|
saveInFlight.current = 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/>
|
|
<BsiCatalogLibrary/>
|
|
<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}/>
|
|
{pendingClientChange && <ClientInformationChangeDialog changes={pendingClientChange.changes} onCancel={cancelClientChange}
|
|
onConfirm={() => { const approved = pendingClientChange.form; setPendingClientChange(null); void persistProject(approved); }}/>}
|
|
</div>;
|
|
}
|