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:
@@ -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) =>
|
||||||
|
|||||||
@@ -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"
|
||||||
</p>
|
aria-valuemin={0}
|
||||||
{#if importResult.errors.length}
|
aria-valuemax={importTotal}
|
||||||
<ul class="import-errors">
|
aria-valuenow={importProcessed}
|
||||||
{#each importResult.errors as err}
|
>
|
||||||
<li><span class="err-file">{err.filename}</span> — {err.reason}</li>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if importItems.length > 0}
|
||||||
|
<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 ---- */
|
||||||
|
|||||||
Reference in New Issue
Block a user