feat(frontend): show import progress bar and per-file status

Consume the import endpoint's NDJSON progress stream via a new postStream
client helper (reuses the bearer token and 401 refresh, but keeps the body
as a stream). The Settings import card now renders a live progress bar
(processed/total) and a scrolling per-file list where each entry shows its
status — imported, skipped or error — with the failure reason inline and the
newest row kept in view. A final summary replaces the old single-shot result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:15:50 +03:00
parent 129cc59793
commit c8bd8512ce
2 changed files with 247 additions and 28 deletions
+60
View File
@@ -166,6 +166,66 @@ export function uploadWithProgress<T>(
}); });
} }
/** POST that consumes a streamed newline-delimited JSON (NDJSON) response,
* invoking onEvent once per parsed line. Used by the server-side import so the
* UI can render live per-file progress. Reuses the bearer token and a single
* 401 refresh+retry, but (unlike request()) keeps the body as a stream. */
export async function postStream(
path: string,
body: unknown,
onEvent: (ev: Record<string, unknown>) => void
): Promise<void> {
const init: RequestInit = { method: 'POST', body: JSON.stringify(body) };
const send = () =>
fetch(BASE + path, { ...init, headers: buildHeaders(init, get(authStore).accessToken) });
let res = await send();
if (res.status === 401) {
if (!refreshPromise) {
refreshPromise = refreshTokens().finally(() => {
refreshPromise = null;
});
}
try {
await refreshPromise;
} catch {
throw new ApiError(401, 'unauthorized', 'Session expired');
}
res = await send();
}
if (!res.ok || !res.body) {
let b: { code?: string; message?: string } = {};
try {
b = await res.json();
} catch {
// ignore parse failure
}
throw new ApiError(res.status, b.code ?? 'error', b.message ?? res.statusText);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
const flushLine = (line: string) => {
const trimmed = line.trim();
if (trimmed) onEvent(JSON.parse(trimmed));
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf('\n')) >= 0) {
flushLine(buf.slice(0, nl));
buf = buf.slice(nl + 1);
}
}
buf += decoder.decode();
flushLine(buf);
}
export const api = { export const api = {
get: <T>(path: string) => request<T>(path), get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) => post: <T>(path: string, body?: unknown) =>
+185 -26
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { api, ApiError } from '$lib/api/client'; import { api, ApiError, postStream } from '$lib/api/client';
import { authStore } from '$lib/stores/auth'; import { authStore } from '$lib/stores/auth';
import { themeStore, toggleTheme } from '$lib/stores/theme'; import { themeStore, toggleTheme } from '$lib/stores/theme';
import { appSettings } from '$lib/stores/appSettings'; import { appSettings } from '$lib/stores/appSettings';
@@ -100,23 +100,66 @@
} }
// ---- Server-side import (admin only) ---- // ---- Server-side import (admin only) ----
interface ImportResult { // The backend streams NDJSON progress events (start → file… → done); we render
imported: number; // a live progress bar and a per-file status list as they arrive.
skipped: number; type ImportStatus = 'imported' | 'skipped' | 'error';
errors: { filename: string; reason: string }[]; interface ImportItem {
filename: string;
status: ImportStatus;
reason?: string;
} }
let importPath = $state(''); let importPath = $state('');
let importing = $state(false); let importing = $state(false);
let importError = $state(''); let importError = $state('');
let importResult = $state<ImportResult | null>(null); let importTotal = $state(0);
let importProcessed = $state(0);
let importDone = $state(false);
let importItems = $state<ImportItem[]>([]);
let importSummary = $state<{ imported: number; skipped: number; errors: number } | null>(null);
let importListEl = $state<HTMLUListElement | null>(null);
// Keep the newest row in view as files stream in.
$effect(() => {
importItems.length;
if (importListEl) importListEl.scrollTop = importListEl.scrollHeight;
});
async function runImport() { async function runImport() {
importing = true; importing = true;
importError = ''; importError = '';
importResult = null; importTotal = 0;
importProcessed = 0;
importDone = false;
importItems = [];
importSummary = null;
try { try {
const sub = importPath.trim(); const sub = importPath.trim();
importResult = await api.post<ImportResult>('/files/import', sub ? { path: sub } : {}); await postStream('/files/import', sub ? { path: sub } : {}, (ev) => {
switch (ev.type) {
case 'start':
importTotal = (ev.total as number) ?? 0;
break;
case 'file':
importProcessed = (ev.index as number) ?? importProcessed + 1;
importItems.push({
filename: (ev.filename as string) ?? '',
status: (ev.status as ImportStatus) ?? 'skipped',
reason: (ev.reason as string) || undefined
});
break;
case 'done':
importDone = true;
importSummary = {
imported: (ev.imported as number) ?? 0,
skipped: (ev.skipped as number) ?? 0,
errors: (ev.errors as number) ?? 0
};
break;
case 'error':
importError = (ev.reason as string) ?? 'Import failed';
break;
}
});
} catch (e) { } catch (e) {
importError = e instanceof ApiError ? e.message : 'Import failed'; importError = e instanceof ApiError ? e.message : 'Import failed';
} finally { } finally {
@@ -344,17 +387,50 @@
{#if importError} {#if importError}
<p class="msg error" role="alert">{importError}</p> <p class="msg error" role="alert">{importError}</p>
{/if} {/if}
{#if importResult}
<p class="msg success" role="status"> {#if importing || importDone || importItems.length > 0}
Imported {importResult.imported}, skipped {importResult.skipped}{importResult.errors <div class="import-progress">
.length <div
? `, ${importResult.errors.length} error${importResult.errors.length === 1 ? '' : 's'}` class="progress-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={importTotal}
aria-valuenow={importProcessed}
>
<div
class="progress-fill"
class:done={importDone}
style:width={importTotal > 0
? `${Math.round((importProcessed / importTotal) * 100)}%`
: importDone
? '100%'
: '0%'}
></div>
</div>
<p class="progress-label">
{#if importDone && importSummary}
Done — imported {importSummary.imported}, skipped {importSummary.skipped}{importSummary.errors
? `, ${importSummary.errors} error${importSummary.errors === 1 ? '' : 's'}`
: ''}. : ''}.
{:else if importTotal > 0}
Importing… {importProcessed}/{importTotal}
{:else}
Scanning…
{/if}
</p> </p>
{#if importResult.errors.length} </div>
<ul class="import-errors">
{#each importResult.errors as err} {#if importItems.length > 0}
<li><span class="err-file">{err.filename}</span>{err.reason}</li> <ul class="import-list" bind:this={importListEl}>
{#each importItems as item}
<li class="import-item {item.status}">
<span class="status-dot" aria-hidden="true"></span>
<span class="item-file" title={item.filename}>{item.filename}</span>
<span class="item-status">{item.status}</span>
{#if item.reason}
<span class="item-reason">{item.reason}</span>
{/if}
</li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
@@ -612,24 +688,107 @@
} }
/* ---- Server import ---- */ /* ---- Server import ---- */
.import-errors { .import-progress {
list-style: none;
margin: 0;
padding: 8px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
border-radius: 7px; }
background-color: color-mix(in srgb, var(--color-danger) 10%, transparent);
.progress-track {
height: 8px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-accent) 15%, var(--color-bg-primary));
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background-color: var(--color-accent);
transition:
width 0.2s ease,
background-color 0.2s ease;
}
.progress-fill.done {
background-color: #7ecba1;
}
.progress-label {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
max-height: 180px; margin: 0;
}
.import-list {
list-style: none;
margin: 0;
padding: 6px 0;
display: flex;
flex-direction: column;
gap: 1px;
max-height: 220px;
overflow-y: auto; overflow-y: auto;
} }
.import-errors .err-file { .import-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.8rem;
}
.status-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
align-self: center;
background-color: var(--color-text-muted);
}
.import-item.imported .status-dot {
background-color: #7ecba1;
}
.import-item.error .status-dot {
background-color: var(--color-danger);
}
.item-file {
color: var(--color-text-primary); color: var(--color-text-primary);
font-weight: 600; font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 45%;
}
.import-item.skipped .item-file {
color: var(--color-text-muted);
font-weight: 500;
}
.item-status {
flex-shrink: 0;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.import-item.error .item-status {
color: var(--color-danger);
}
.item-reason {
flex: 1;
min-width: 0;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
/* ---- Sessions ---- */ /* ---- Sessions ---- */