import { beginActivity } from '../../platform/activity-store'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract'; import { AssessorApiClient } from '../../api/assessor-api-client'; import { OperationProgressDialog } from '../../components/OperationProgress/OperationProgress'; import { missingGpoSourceMessage, unavailableGpoStorageMessage } from './gpo-source-recovery'; import { createGpoFolderTransport, validateGpoFolderSourceMetadata, type GpoFolderFileSource } from '../../gpo/gpo-folder-transport'; const assessorApi = new AssessorApiClient(); interface GpoFolderImportProps { restoreArtifactId?: string; disabled?: boolean; projectId: string; assessmentId?: string; showStatus?: boolean; label?: string; archiveLabel?: string; } interface Feedback { tone: 'success' | 'error'; message: string; } interface BrowserFileSystemFileHandle { kind: 'file'; name: string; getFile(): Promise; } interface BrowserFileSystemDirectoryHandle { kind: 'directory'; name: string; values(): AsyncIterableIterator; } interface BrowserDirectoryPickerGlobal { showDirectoryPicker?: (options?: { mode?: 'read' }) => Promise; } interface SelectedBrowserFile { file: File; relativePath: string; } interface CollectedBrowserDirectory { files: SelectedBrowserFile[]; unreadableEntries: number; failures: FolderReadFailure[]; } interface FolderReadFailure { stage: 'Folder enumeration' | 'File access' | 'File reading' | 'Folder selection'; code: string; } function folderReadFailure(stage: FolderReadFailure['stage'], reason: unknown): FolderReadFailure { // Browser messages may contain customer paths. Retain only known error names. const name = reason instanceof Error || reason instanceof DOMException ? reason.name : ''; const known = ['NotFoundError', 'NotAllowedError', 'SecurityError', 'NotReadableError', 'AbortError']; return { stage, code: known.includes(name) ? name : 'UnknownError' }; } function folderReadMessage(failures: readonly FolderReadFailure[]): string { if (!failures.length) return 'The selected GPO folder contains no files. Select the extracted backup folder containing files, or use Import GPO ZIP.'; const counts = new Map(); for (const failure of failures) { const key = `${failure.stage}: ${failure.code}`; counts.set(key, (counts.get(key) ?? 0) + 1); } const detail = [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${key} (${count})`).join('; '); const denied = failures.some(failure => ['NotAllowedError', 'SecurityError'].includes(failure.code)); return `Chrome/browser could not read the selected GPO folder. ${detail}. ${denied ? 'Select the folder again and allow read access.' : 'Check that the backup files are locally available and readable, then select the folder again.'} Alternatively, use Import GPO ZIP. No upload was started.`; } const stateLabels: Record = { queued: 'Queued', running: 'Processing', succeeded: 'Processed successfully', partial: 'Imported with review items', 'dead-letter': 'Failed', 'upload-required': 'Upload required', 'storage-unavailable': 'Storage unavailable', 'recovery-required': 'Recovery pending' }; const errorLabels: Record = { 'gpo-inventory-memory-limit': 'Speichergrenze des isolierten Analyse-Workers überschritten', 'gpo-inventory-time-limit': 'Time limit of the isolated analysis worker exceeded', 'gpo-inventory-source-size-limit': 'GPO package exceeds the allowed input size', 'gpo-analysis-reference-missing': 'Stored analysis source is not available', 'gpo-analysis-persistence-invalid': 'Analysis result could not be saved safely' }; function formatBytes(value: number): string { if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; return `${(value / (1024 * 1024)).toFixed(1)} MB`; } function formatTimestamp(value: string): string { return new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value)); } export function captureGpoSelectedFiles(entries: readonly SelectedBrowserFile[]): GpoFolderFileSource[] { validateGpoFolderSourceMetadata(entries.map(entry => ({ relativePath: entry.relativePath, sizeBytes: entry.file.size }))); return entries.map(({ file, relativePath }) => { // Das Lesen muss fuer alle Browser-Dateireferenzen noch synchron im // Auswahlereignis beginnen. Spaeteres sequenzielles Oeffnen kann unter // Windows bei Verzeichnis-Uploads mit NotFoundError abbrechen. const captured = file.arrayBuffer().then( buffer => ({ bytes: new Uint8Array(buffer) }), () => ({ error: true as const }) ); return { relativePath, sizeBytes: file.size, readBytes: async () => { const result = await captured; if ('error' in result) { throw new Error('Eine ausgewaehlte GPO-Datei konnte nicht lokal gelesen werden. Bitte stellen Sie den gesamten Ordner lokal bereit und waehlen Sie ihn erneut aus.'); } return result.bytes; } }; }); } export function captureGpoFolderFiles(files: readonly File[]): GpoFolderFileSource[] { return captureGpoSelectedFiles(files.map(file => ({ file, relativePath: file.webkitRelativePath }))); } export async function captureReadableGpoSelectedFiles( entries: readonly SelectedBrowserFile[] ): Promise<{ sources: GpoFolderFileSource[]; unreadableEntries: number; failures: FolderReadFailure[] }> { const failures: FolderReadFailure[] = []; validateGpoFolderSourceMetadata(entries.map(entry => ({ relativePath: entry.relativePath, sizeBytes: entry.file.size }))); const captured: Array = await Promise.all(entries.map(async ({ file, relativePath }): Promise => { try { const bytes = new Uint8Array(await file.arrayBuffer()); if (bytes.byteLength !== file.size) { failures.push({ stage: 'File reading', code: 'SizeMismatch' }); return undefined; } return { relativePath, sizeBytes: file.size, readBytes: async () => bytes }; } catch (reason) { failures.push(folderReadFailure('File reading', reason)); return undefined; } })); const sources = captured.filter((source): source is GpoFolderFileSource => source !== undefined); return { sources, unreadableEntries: entries.length - sources.length, failures }; } export async function collectGpoDirectoryFiles( root: BrowserFileSystemDirectoryHandle ): Promise { const selected: SelectedBrowserFile[] = []; let unreadableEntries = 0; const failures: FolderReadFailure[] = []; const visit = async (directory: BrowserFileSystemDirectoryHandle, parentPath: string): Promise => { try { for await (const entry of directory.values()) { const relativePath = `${parentPath}/${entry.name}`; if (entry.kind === 'directory') await visit(entry, relativePath); else { try { selected.push({ file: await entry.getFile(), relativePath }); } catch (reason) { unreadableEntries += 1; failures.push(folderReadFailure('File access', reason)); } } } } catch (reason) { unreadableEntries += 1; failures.push(folderReadFailure('Folder enumeration', reason)); } }; await visit(root, root.name); return { files: selected, unreadableEntries, failures }; } export function GpoFolderImport({ restoreArtifactId, disabled = false, projectId, assessmentId, showStatus = true, label = 'Import GPO Folder', archiveLabel = 'Import GPO ZIP' }: GpoFolderImportProps) { const folderInputRef = useRef(null); const archiveInputRef = useRef(null); const [busy, setBusy] = useState(false); const [feedback, setFeedback] = useState(null); const [records, setRecords] = useState([]); const [statusError, setStatusError] = useState(); const [managedArtifactId, setManagedArtifactId] = useState(); const jobActivity = useRef | undefined>(undefined); const [processingJob, setProcessingJob] = useState<{ artifactId: string; jobId: string }>(); const [progress, setProgress] = useState(); useEffect(() => { if (!processingJob) return; const activity = beginActivity('Core Engine', 'GPO processing queued'); activity.queue(); jobActivity.current = activity; return () => { activity.update('Stopped monitoring GPO processing'); activity.cancel(); jobActivity.current = undefined; }; }, [processingJob?.jobId]); const loadStatus = useCallback(async () => { try { const response = await assessorApi.listProjectGpoBackupStatus(projectId); setRecords(response.records); setStatusError(undefined); } catch (reason) { setStatusError(reason instanceof Error ? reason.message : 'The GPO status could not be loaded.'); } }, [projectId]); useEffect(() => { const input = folderInputRef.current; if (input) { input.setAttribute('webkitdirectory', ''); input.setAttribute('directory', ''); } }, []); useEffect(() => { if (!showStatus) return; void loadStatus(); const refresh = (event: Event) => { if (event instanceof CustomEvent && event.detail?.projectId && event.detail.projectId !== projectId) return; void loadStatus(); }; globalThis.addEventListener('focus', refresh); globalThis.addEventListener('assessor:gpo-changed', refresh); return () => { globalThis.removeEventListener('focus', refresh); globalThis.removeEventListener('assessor:gpo-changed', refresh); }; }, [loadStatus, projectId, showStatus]); const hasPendingStatus = records.some(record => record.state === 'queued' || record.state === 'running'); useEffect(() => { // Timeslot rows render this component only as an import control. They must // not each start a permanent status poll. A hidden control polls only its // own submitted job; a visible status panel polls while work is pending. if (!processingJob && !(showStatus && hasPendingStatus)) return; const timer = globalThis.setInterval(() => void loadStatus(), 2_000); return () => globalThis.clearInterval(timer); }, [hasPendingStatus, loadStatus, processingJob, showStatus]); useEffect(() => { if (!processingJob) return; const record = records.find(item => item.artifactId === processingJob.artifactId && item.jobId === processingJob.jobId); if (record?.state === 'running') { jobActivity.current?.run(); jobActivity.current?.update('Processing GPO backup on the server'); } if (record && !['queued', 'running'].includes(record.state)) { if (record.processingStatus === 'failed' || record.jobStatus === 'dead-letter') jobActivity.current?.fail(); else { if (record.processingStatus === 'partial') jobActivity.current?.update('GPO processing completed with partial results'); jobActivity.current?.complete(); } setProcessingJob(undefined); setProgress(undefined); } }, [processingJob, records]); const retry = async (record: GpoBackupStatusRecord) => { setManagedArtifactId(record.artifactId); setProgress('Starting GPO processing'); setFeedback(null); try { const result = await assessorApi.retryGpoBackup(projectId, record.artifactId); setProcessingJob({ artifactId: result.artifactId, jobId: result.jobId }); setProgress('Processing GPO backup'); setFeedback({ tone: 'success', message: 'The GPO analysis job was requeued.' }); await loadStatus(); } catch (reason) { setProgress(undefined); setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'The GPO analysis job could not be restarted.' }); } finally { setManagedArtifactId(undefined); } }; const deleteFailed = async (record: GpoBackupStatusRecord) => { if (!globalThis.confirm(`Permanently delete the failed GPO import "${record.fileName}" from ASSESSOR? The source files on your machine remain unchanged.`)) return; setManagedArtifactId(record.artifactId); setFeedback(null); try { const result = await assessorApi.deleteGpoBackup(projectId, record.artifactId); setFeedback({ tone: 'success', message: result.storageRemoval === 'complete' ? 'Failed GPO import and its stored package copy were deleted.' : 'The database entry was deleted; the package copy is in the secure deletion queue.' }); await loadStatus(); } catch (reason) { setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'The failed GPO import could not be deleted.' }); } finally { setManagedArtifactId(undefined); } }; const announceSubmission = async (file: File, successMessage: (deduplicated: boolean) => string) => { setProgress('Uploading GPO backup'); const result = await assessorApi.submitGpoBackup(projectId, file, assessmentId, restoreArtifactId); setProcessingJob({ artifactId: result.artifactId, jobId: result.jobId }); setProgress('Processing GPO backup'); setFeedback({ tone: 'success', message: result.sourceRestored || restoreArtifactId ? 'Original GPO source restored. Saved results are preserved.' : successMessage(result.deduplicated) }); globalThis.dispatchEvent(new CustomEvent('assessor:gpo-changed', { detail: { projectId, jobId: result.jobId } })); await loadStatus(); }; const submitFolder = async (files: readonly File[]) => { const activity = beginActivity('Core Engine', 'Preparing and submitting GPO backup'); setBusy(true); setProgress('Preparing GPO backup'); setFeedback(null); try { if (files.some(file => !file.webkitRelativePath)) throw new Error('Please select a complete GPO backup folder.'); const captured = await captureReadableGpoSelectedFiles(files.map(file => ({ file, relativePath: file.webkitRelativePath }))); if (!captured.sources.length) throw new Error(folderReadMessage(captured.failures)); const transport = await createGpoFolderTransport(captured.sources); const envelopeBytes = new Uint8Array(transport.bytes.byteLength); envelopeBytes.set(transport.bytes); const envelope = new File([envelopeBytes.buffer], transport.fileName, { type: 'application/zip' }); await announceSubmission(envelope, deduplicated => deduplicated ? `GPO backup folder already exists · analysis job confirmed (${transport.fileCount} files${captured.unreadableEntries ? `; ${captured.unreadableEntries} unreadable skipped` : ''}).` : `GPO backup folder accepted · analysis job queued (${transport.fileCount} files${captured.unreadableEntries ? `; ${captured.unreadableEntries} unreadable skipped` : ''}).`); } catch (reason) { if (reason instanceof DOMException && reason.name === 'AbortError') activity.cancel(); else activity.fail(); setProgress(undefined); setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup folder could not be imported.' }); } finally { activity.complete(); setBusy(false); } }; const chooseFolder = async () => { const picker = (globalThis as unknown as BrowserDirectoryPickerGlobal).showDirectoryPicker; if (!picker) { folderInputRef.current?.click(); return; } const activity = beginActivity('Core Engine', 'Preparing and submitting GPO backup'); setBusy(true); setProgress('Preparing GPO backup'); setFeedback(null); try { let directory: BrowserFileSystemDirectoryHandle; try { directory = await picker.call(globalThis, { mode: 'read' }); } catch (reason) { if (reason instanceof DOMException && reason.name === 'AbortError') throw reason; throw new Error(folderReadMessage([folderReadFailure('Folder selection', reason)])); } const collected = await collectGpoDirectoryFiles(directory); if (!collected.files.length) throw new Error(folderReadMessage(collected.failures)); const captured = await captureReadableGpoSelectedFiles(collected.files); const unreadableEntries = collected.unreadableEntries + captured.unreadableEntries; if (!captured.sources.length) throw new Error(folderReadMessage([...collected.failures, ...captured.failures])); const transport = await createGpoFolderTransport(captured.sources); const envelopeBytes = new Uint8Array(transport.bytes.byteLength); envelopeBytes.set(transport.bytes); const envelope = new File([envelopeBytes.buffer], transport.fileName, { type: 'application/zip' }); await announceSubmission(envelope, deduplicated => deduplicated ? `GPO backup folder already exists · analysis job confirmed (${transport.fileCount} files${unreadableEntries ? `; ${unreadableEntries} unreadable skipped` : ''}).` : `GPO backup folder accepted · analysis job queued (${transport.fileCount} files${unreadableEntries ? `; ${unreadableEntries} unreadable skipped` : ''}).`); } catch (reason) { if (reason instanceof DOMException && reason.name === 'AbortError') activity.cancel(); else activity.fail(); if (reason instanceof DOMException && reason.name === 'AbortError') { setProgress(undefined); return; } setProgress(undefined); setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup folder could not be imported.' }); } finally { activity.complete(); setBusy(false); } }; const submitArchive = async (file: File) => { setBusy(true); setProgress('Uploading GPO backup'); setFeedback(null); try { if (!file.name.toLocaleLowerCase('en-US').endsWith('.zip')) { throw new Error('Please select a ZIP file containing a GPO backup.'); } await announceSubmission(file, deduplicated => deduplicated ? 'GPO backup ZIP already exists · analysis job confirmed.' : 'GPO backup ZIP accepted · analysis job queued.'); } catch (reason) { setProgress(undefined); setFeedback({ tone: 'error', message: reason instanceof Error ? reason.message : 'GPO backup ZIP could not be imported.' }); } finally { setBusy(false); } }; return <> { const input = event.currentTarget; const selectedFiles = Array.from(input.files ?? []); if (selectedFiles.length) void submitFolder(selectedFiles).finally(() => { input.value = ''; }); }} /> { const input = event.currentTarget; const selectedArchive = input.files?.[0]; if (selectedArchive) void submitArchive(selectedArchive).finally(() => { input.value = ''; }); }} /> {feedback && {feedback.message} } {showStatus &&
GPO Processing
{statusError &&

{statusError}

} {!statusError && records.length === 0 &&

No tenant GPO backup snapshots for this client yet.

} {records.length > 0 &&
{records.map(record =>
{record.fileName} {formatBytes(record.sizeBytes)} · Received {formatTimestamp(record.submittedAt)}
{stateLabels[record.state]} {record.state === 'storage-unavailable' && {unavailableGpoStorageMessage}} {record.state === 'upload-required' && <> {missingGpoSourceMessage} } Attempt {record.attempts}/{record.maxAttempts} · updated {formatTimestamp(record.updatedAt)} {record.gpoCount > 0 && 1 tenant GPO backup · {record.gpoCount} GPO{record.gpoCount === 1 ? '' : 's'} detected} {record.lastErrorCode && Last error: {errorLabels[record.lastErrorCode] ?? 'Technical analysis error'} {record.lastErrorCode} } {record.state === 'dead-letter' &&
}
)}
}
} ; }