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

488 lines
22 KiB
TypeScript

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<File>;
}
interface BrowserFileSystemDirectoryHandle {
kind: 'directory';
name: string;
values(): AsyncIterableIterator<BrowserFileSystemFileHandle | BrowserFileSystemDirectoryHandle>;
}
interface BrowserDirectoryPickerGlobal {
showDirectoryPicker?: (options?: { mode?: 'read' }) => Promise<BrowserFileSystemDirectoryHandle>;
}
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<string, number>();
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<GpoBackupStatusRecord['state'], string> = {
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<string, string> = {
'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<GpoFolderFileSource | undefined> = await Promise.all(entries.map(async ({ file, relativePath }): Promise<GpoFolderFileSource | undefined> => {
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<CollectedBrowserDirectory> {
const selected: SelectedBrowserFile[] = [];
let unreadableEntries = 0;
const failures: FolderReadFailure[] = [];
const visit = async (directory: BrowserFileSystemDirectoryHandle, parentPath: string): Promise<void> => {
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<HTMLInputElement>(null);
const archiveInputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [records, setRecords] = useState<GpoBackupStatusRecord[]>([]);
const [statusError, setStatusError] = useState<string>();
const [managedArtifactId, setManagedArtifactId] = useState<string>();
const jobActivity = useRef<ReturnType<typeof beginActivity> | undefined>(undefined);
const [processingJob, setProcessingJob] = useState<{ artifactId: string; jobId: string }>();
const [progress, setProgress] = useState<string>();
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 <>
<span className={`gpo-folder-actions${restoreArtifactId ? ' gpo-source-recovery' : ''}`}>
<input
ref={folderInputRef}
className="hidden-input"
type="file"
multiple
onChange={event => {
const input = event.currentTarget;
const selectedFiles = Array.from(input.files ?? []);
if (selectedFiles.length) void submitFolder(selectedFiles).finally(() => { input.value = ''; });
}}
/>
<input
ref={archiveInputRef}
className="hidden-input"
type="file"
accept=".zip,application/zip"
onChange={event => {
const input = event.currentTarget;
const selectedArchive = input.files?.[0];
if (selectedArchive) void submitArchive(selectedArchive).finally(() => { input.value = ''; });
}}
/>
<button className="btn primary" type="button" disabled={busy || disabled} onClick={() => void chooseFolder()}>
{busy ? 'Checking GPO …' : label}
</button>
<button className="btn primary" type="button" disabled={busy || disabled} onClick={() => archiveInputRef.current?.click()}>
{busy ? 'Checking GPO …' : archiveLabel}
</button>
{feedback && <span className={`gpo-folder-feedback ${feedback.tone}`} role={feedback.tone === 'error' ? 'alert' : 'status'}>
{feedback.message}
</span>}
</span>
{showStatus && <section className="gpo-status-panel" aria-label="GPO processing status" aria-live="polite">
<div className="gpo-status-heading">
<strong>GPO Processing</strong>
<button className="btn compact" type="button" onClick={() => void loadStatus()}>Refresh</button>
</div>
{statusError && <p className="gpo-folder-feedback error" role="alert">{statusError}</p>}
{!statusError && records.length === 0 && <p className="gpo-status-empty">No tenant GPO backup snapshots for this client yet.</p>}
{records.length > 0 && <div className="gpo-status-list">
{records.map(record => <article className={`gpo-status-item state-${record.state}`} key={record.artifactId}>
<div>
<strong>{record.fileName}</strong>
<span>{formatBytes(record.sizeBytes)} · Received {formatTimestamp(record.submittedAt)}</span>
</div>
<div className="gpo-status-result">
<strong>{stateLabels[record.state]}</strong>
{record.state === 'storage-unavailable' && <span>{unavailableGpoStorageMessage}</span>}
{record.state === 'upload-required' && <>
<span>{missingGpoSourceMessage}</span>
<GpoFolderImport projectId={projectId} assessmentId={record.assessmentId} restoreArtifactId={record.artifactId}
showStatus={false} label="Upload original folder" archiveLabel="Upload original ZIP"/>
</>}
<span>Attempt {record.attempts}/{record.maxAttempts} · updated {formatTimestamp(record.updatedAt)}</span>
{record.gpoCount > 0 && <span>1 tenant GPO backup · {record.gpoCount} GPO{record.gpoCount === 1 ? '' : 's'} detected</span>}
{record.lastErrorCode && <span className="gpo-status-error">
Last error: {errorLabels[record.lastErrorCode] ?? 'Technical analysis error'} <code>{record.lastErrorCode}</code>
</span>}
{record.state === 'dead-letter' && <div className="gpo-status-actions">
<button className="btn compact" type="button" disabled={managedArtifactId === record.artifactId} onClick={() => void retry(record)}>Retry</button>
<button className="btn compact danger" type="button" disabled={managedArtifactId === record.artifactId} onClick={() => void deleteFailed(record)}>Delete Import</button>
</div>}
</div>
</article>)}
</div>}
</section>}
<OperationProgressDialog label={progress}/>
</>;
}