feat(admin): mass import card on system tab with live status polling
Adds a new card on the System tab that triggers the existing POST /api/admin/trigger-import endpoint. Status is polled every 2 s while RUNNING and stops automatically on DONE or FAILED. IDLE/RUNNING/DONE/FAILED states each render distinct UI feedback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -273,6 +273,14 @@
|
|||||||
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
|
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
|
||||||
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
|
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
|
||||||
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
|
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
|
||||||
|
"admin_system_import_heading": "Massenimport",
|
||||||
|
"admin_system_import_description": "Importiert Dokumente und Metadaten aus der Importdatei im /import-Verzeichnis.",
|
||||||
|
"admin_system_import_btn_start": "Import starten",
|
||||||
|
"admin_system_import_btn_retry": "Erneut starten",
|
||||||
|
"admin_system_import_status_idle": "Kein Import gestartet.",
|
||||||
|
"admin_system_import_status_running": "Import läuft…",
|
||||||
|
"admin_system_import_status_done": "Import abgeschlossen – {count} Dokumente verarbeitet.",
|
||||||
|
"admin_system_import_status_failed": "Fehler: {message}",
|
||||||
"comp_expandable_show_more": "Mehr anzeigen",
|
"comp_expandable_show_more": "Mehr anzeigen",
|
||||||
"comp_expandable_show_less": "Weniger anzeigen",
|
"comp_expandable_show_less": "Weniger anzeigen",
|
||||||
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||||
|
|||||||
@@ -273,6 +273,14 @@
|
|||||||
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
|
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
|
||||||
"admin_system_backfill_hashes_btn": "Compute file hashes",
|
"admin_system_backfill_hashes_btn": "Compute file hashes",
|
||||||
"admin_system_backfill_hashes_success": "{count} documents were updated.",
|
"admin_system_backfill_hashes_success": "{count} documents were updated.",
|
||||||
|
"admin_system_import_heading": "Mass import",
|
||||||
|
"admin_system_import_description": "Imports documents and metadata from the spreadsheet file in the /import directory.",
|
||||||
|
"admin_system_import_btn_start": "Start import",
|
||||||
|
"admin_system_import_btn_retry": "Start again",
|
||||||
|
"admin_system_import_status_idle": "No import started.",
|
||||||
|
"admin_system_import_status_running": "Import running…",
|
||||||
|
"admin_system_import_status_done": "Import complete – {count} documents processed.",
|
||||||
|
"admin_system_import_status_failed": "Error: {message}",
|
||||||
"comp_expandable_show_more": "Show more",
|
"comp_expandable_show_more": "Show more",
|
||||||
"comp_expandable_show_less": "Show less",
|
"comp_expandable_show_less": "Show less",
|
||||||
"error_comment_not_found": "The comment could not be found.",
|
"error_comment_not_found": "The comment could not be found.",
|
||||||
|
|||||||
@@ -273,6 +273,14 @@
|
|||||||
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
|
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
|
||||||
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
|
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
|
||||||
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
|
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
|
||||||
|
"admin_system_import_heading": "Importación masiva",
|
||||||
|
"admin_system_import_description": "Importa documentos y metadatos desde el archivo en el directorio /import.",
|
||||||
|
"admin_system_import_btn_start": "Iniciar importación",
|
||||||
|
"admin_system_import_btn_retry": "Iniciar de nuevo",
|
||||||
|
"admin_system_import_status_idle": "No hay importación iniciada.",
|
||||||
|
"admin_system_import_status_running": "Importación en curso…",
|
||||||
|
"admin_system_import_status_done": "Importación completada – {count} documentos procesados.",
|
||||||
|
"admin_system_import_status_failed": "Error: {message}",
|
||||||
"comp_expandable_show_more": "Mostrar más",
|
"comp_expandable_show_more": "Mostrar más",
|
||||||
"comp_expandable_show_less": "Mostrar menos",
|
"comp_expandable_show_less": "Mostrar menos",
|
||||||
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
||||||
|
|||||||
@@ -6,6 +6,55 @@ let backfillLoading = $state(false);
|
|||||||
let backfillHashesResult: number | null = $state(null);
|
let backfillHashesResult: number | null = $state(null);
|
||||||
let backfillHashesLoading = $state(false);
|
let backfillHashesLoading = $state(false);
|
||||||
|
|
||||||
|
type ImportStatus = {
|
||||||
|
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||||
|
message: string;
|
||||||
|
processed: number;
|
||||||
|
startedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let importStatus: ImportStatus | null = $state(null);
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollInterval) return;
|
||||||
|
pollInterval = setInterval(fetchImportStatus, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchImportStatus() {
|
||||||
|
const res = await fetch('/api/admin/import-status');
|
||||||
|
if (res.ok) {
|
||||||
|
importStatus = await res.json();
|
||||||
|
if (importStatus!.state === 'RUNNING') {
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerImport() {
|
||||||
|
const res = await fetch('/api/admin/trigger-import', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
importStatus = await res.json();
|
||||||
|
if (importStatus!.state === 'RUNNING') {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
fetchImportStatus();
|
||||||
|
return () => stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
async function backfillVersions() {
|
async function backfillVersions() {
|
||||||
backfillLoading = true;
|
backfillLoading = true;
|
||||||
backfillResult = null;
|
backfillResult = null;
|
||||||
@@ -74,5 +123,48 @@ async function backfillFileHashes() {
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mass import -->
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.admin_system_import_heading()}</h2>
|
||||||
|
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_description()}</p>
|
||||||
|
|
||||||
|
{#if importStatus?.state === 'RUNNING'}
|
||||||
|
<p class="text-sm text-ink-2">{m.admin_system_import_status_running()}</p>
|
||||||
|
{:else if importStatus?.state === 'DONE'}
|
||||||
|
<p class="mb-4 rounded-sm border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||||
|
{m.admin_system_import_status_done({ count: importStatus.processed })}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
data-import-trigger
|
||||||
|
onclick={triggerImport}
|
||||||
|
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{m.admin_system_import_btn_retry()}
|
||||||
|
</button>
|
||||||
|
{:else if importStatus?.state === 'FAILED'}
|
||||||
|
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{m.admin_system_import_status_failed({ message: importStatus.message })}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
data-import-trigger
|
||||||
|
onclick={triggerImport}
|
||||||
|
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{m.admin_system_import_btn_retry()}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{#if importStatus !== null}
|
||||||
|
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_status_idle()}</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
data-import-trigger
|
||||||
|
onclick={triggerImport}
|
||||||
|
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{m.admin_system_import_btn_start()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
describe('Admin system page', () => {
|
describe('Admin system page', () => {
|
||||||
it('renders the backfill versions heading', async () => {
|
it('renders the backfill versions heading', async () => {
|
||||||
@@ -32,3 +33,104 @@ describe('Admin system page', () => {
|
|||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Admin system page — mass import card', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
state: 'IDLE',
|
||||||
|
message: 'Kein Import gestartet.',
|
||||||
|
processed: 0,
|
||||||
|
startedAt: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the mass import heading', async () => {
|
||||||
|
render(Page, {});
|
||||||
|
await expect.element(page.getByText(/Massenimport/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the start import button when idle', async () => {
|
||||||
|
render(Page, {});
|
||||||
|
await expect.element(page.getByRole('button', { name: /Import starten/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows idle status text', async () => {
|
||||||
|
render(Page, {});
|
||||||
|
await expect.element(page.getByText(/Kein Import gestartet/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the start button and shows running state after click', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
// initial status fetch → IDLE
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
state: 'IDLE',
|
||||||
|
message: 'Kein Import gestartet.',
|
||||||
|
processed: 0,
|
||||||
|
startedAt: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// trigger POST → returns RUNNING immediately
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
state: 'RUNNING',
|
||||||
|
message: 'Import läuft...',
|
||||||
|
processed: 0,
|
||||||
|
startedAt: '2026-01-01T10:00:00'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(Page, {});
|
||||||
|
await expect.element(page.getByRole('button', { name: /Import starten/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
document.querySelector<HTMLButtonElement>('[data-import-trigger]')!.click();
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Import läuft/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows done status and retry button after successful import', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
state: 'DONE',
|
||||||
|
message: 'Import abgeschlossen.',
|
||||||
|
processed: 42,
|
||||||
|
startedAt: '2026-01-01T10:00:00'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(Page, {});
|
||||||
|
await expect.element(page.getByText(/42 Dokumente/i)).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /Erneut starten/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows failed status and retry button on error', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
state: 'FAILED',
|
||||||
|
message: 'Datei nicht gefunden.',
|
||||||
|
processed: 0,
|
||||||
|
startedAt: '2026-01-01T10:00:00'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(Page, {});
|
||||||
|
await expect.element(page.getByText(/Datei nicht gefunden/i)).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /Erneut starten/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user