488 lines
22 KiB
TypeScript
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}/>
|
|
</>;
|
|
}
|
|
|