Frontend Feature Changes

This commit is contained in:
sriram
2026-09-02 12:27:10 +05:30
parent b677ddf9e6
commit 2108e402b0
7 changed files with 869 additions and 513 deletions

View File

@@ -1,15 +1,21 @@
import React, { useEffect, useState } from 'react';
import { Wrench, Database, Layers, Inbox, Workflow } from 'lucide-react';
import { Wrench, Database, Layers, ClipboardCheck, Workflow } from 'lucide-react';
import { api } from '../api/client';
import { NavigationHeader } from '../components/NavigationHeader';
import { BatchCatalogPanel } from './BatchCatalogPanel';
import { InboxPanel } from './InboxPanel';
import { UploadResultsPanel } from './UploadResultsPanel';
import { OrchestrationPanel } from './OrchestrationPanel';
/*
* The admin panel carries the read-only project overview plus three ingestion
* views: a multi-file batch upload, the review inbox, and the Dagster
* orchestration screen.
* views: a multi-file batch upload, the results of a colleague's upload, and
* the Dagster orchestration screen.
*
* "Upload Results" was the Review Inbox until it stopped being useful as one:
* UPLOAD_AUTORUN defaults to true, so a colleague's upload runs on arrival and
* the inbox is permanently empty. The tab now leads with what the pipeline did
* with the newest colleague upload, and the approval gate reveals itself only
* when files are actually waiting - which is what the badge below counts.
*
* TWO KINDS OF REMOVAL HAVE HAPPENED HERE, AND THE DIFFERENCE MATTERS.
*
@@ -30,7 +36,10 @@ import { OrchestrationPanel } from './OrchestrationPanel';
const TABS = [
{ id: 'project', label: 'Project Overview Details', icon: Database },
{ id: 'batch', label: 'Batch Catalog Ingestion', icon: Layers },
{ id: 'inbox', label: 'Review Inbox', icon: Inbox },
// The id stays 'inbox' - the badge, the render guard and the onBatchStarted
// wiring all key off it, and the tab still owns the review inbox; it just
// leads with the pipeline result now.
{ id: 'inbox', label: 'Upload Results', icon: ClipboardCheck },
{ id: 'orchestration', label: 'Dagster Orchestration', icon: Workflow },
];
@@ -64,7 +73,8 @@ export function AdminPage() {
fetchProjectDetails();
}, []);
// The badge. Polled here rather than inside InboxPanel so the count is live
// The badge. Polled here rather than inside UploadResultsPanel so the count
// is live
// whichever tab is open - the whole point is finding out that a colleague
// sent something while you were looking at a different screen. A failed poll
// is swallowed: a transient network blip must not replace the panel with an
@@ -132,7 +142,7 @@ export function AdminPage() {
{activeTab === 'batch' && <BatchCatalogPanel adoptBatch={adoptedBatch} />}
{activeTab === 'inbox' && (
<InboxPanel
<UploadResultsPanel
onBatchStarted={(batch) => {
setAdoptedBatch(batch);
setActiveTab('batch');

View File

@@ -69,7 +69,8 @@ export function BatchCatalogPanel({ adoptBatch = null }) {
const pollRef = useRef(null);
const batchIdRef = useRef(null);
// A batch started from the Review Inbox tab. Without this, switching tabs
// A batch started from the approval gate on the Upload Results tab. Without
// this, switching tabs
// after pressing "Start selected" would land on an empty panel and the run
// would look like it had not happened.
useEffect(() => {

View File

@@ -1,363 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Inbox, Loader2, CheckCircle2, XCircle, AlertTriangle, ArrowRight,
PlayCircle, Ban, FileSpreadsheet, RefreshCw,
} from 'lucide-react';
import { api } from '../api/client';
/*
* Files a colleague dropped, waiting for you to decide.
*
* This is the admin half of the two-actor flow. The colleague never sees this
* page - they POST to /api/uploads/catalog with an API key and that is the end
* of their involvement. Nothing they upload runs until somebody ticks it here.
*
* Selection is per FILE, not per drop, and files from different drops can be
* started as one batch. That is the whole reason the inbox is its own thing
* rather than a list of pre-made batches waiting on Resume: Resume runs a whole
* batch, and "two files from Monday plus one from today" has no expression in
* that model.
*
* Once started, this hands off to the Batch tab, which already knows how to
* render a running batch. Nothing here duplicates that.
*/
const POLL_MS = 5000;
function ago(seconds) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - seconds));
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function fmtBytes(n) {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function InboxPanel({ onBatchStarted }) {
const [data, setData] = useState(null);
const [selected, setSelected] = useState(() => new Set());
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [useLlm, setUseLlm] = useState(true);
// Ticked by default - see the same note in BatchCatalogPanel. A file
// released from the inbox is the main way catalog rows are created, and
// without this they arrive with no images.
const [fetchImages, setFetchImages] = useState(true);
const pollRef = useRef(null);
const load = async () => {
try {
const next = await api.listInbox();
setData(next);
// Drop any selection that is no longer pending - another tab may have
// started or dismissed it, and a checkbox pointing at a file that is
// already gone would 409 on the next click with no explanation.
const live = new Set(
next.submissions.flatMap((s) => s.files.map((f) => f.file_id))
);
setSelected((current) => new Set([...current].filter((id) => live.has(id))));
} catch (err) {
setError(err?.message || 'Could not read the inbox.');
}
};
useEffect(() => {
load();
pollRef.current = setInterval(load, POLL_MS);
return () => clearInterval(pollRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggle = (fileId) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(fileId)) next.delete(fileId);
else next.add(fileId);
return next;
});
};
const toggleDrop = (submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.every((id) => selected.has(id));
setSelected((current) => {
const next = new Set(current);
ids.forEach((id) => (allOn ? next.delete(id) : next.add(id)));
return next;
});
};
const handleStart = async () => {
if (!selected.size) return;
setBusy('start');
setError('');
setNotice('');
try {
const batch = await api.startBatchFromInbox([...selected], {
use_llm: useLlm,
fetch_images: fetchImages,
});
setSelected(new Set());
setNotice(
`Started ${batch.files_total} file(s) as one batch. Follow it on the ` +
`Batch Catalog Ingestion tab.`
);
await load();
if (onBatchStarted) onBatchStarted(batch);
} catch (err) {
setError(err?.message || 'Could not start those files.');
await load();
} finally {
setBusy('');
}
};
const handleDismiss = async () => {
if (!selected.size) return;
setBusy('dismiss');
setError('');
setNotice('');
try {
const result = await api.dismissInboxFiles([...selected]);
setSelected(new Set());
setNotice(`Dismissed ${result.dismissed} file(s). They will not be processed.`);
await load();
} catch (err) {
setError(err?.message || 'Could not dismiss those files.');
await load();
} finally {
setBusy('');
}
};
const submissions = data?.submissions || [];
const pending = data?.pending_count ?? 0;
// Nothing ticked means "Start selected" cannot fire, which makes the two
// stage toggles below inert. On a deployment running UPLOAD_AUTORUN=true that
// is the permanent state - this inbox never fills - so they stay greyed, which
// is exactly the point: they must not read as settings that govern uploads.
const stagesIdle = !selected.size;
return (
<div className="space-y-6">
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<Inbox className="h-5 w-5 text-amber-500" /> Review Inbox
</h2>
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
Where spreadsheets wait <strong>when <code className="font-mono">UPLOAD_AUTORUN</code>
{' '}is off</strong>. Nothing here has run yet &mdash; tick the files you want and
press <strong>Start selected</strong> to send them through the 11-stage pipeline as
one batch. Files from different drops can go in the same batch. With autorun on,
uploads start on arrival and this stays empty; watch them under{' '}
<strong>Dagster Orchestration</strong> instead.
</p>
</div>
<button
type="button"
onClick={load}
aria-label="Refresh the inbox"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-ink-950 transition cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5" /> Refresh
</button>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleStart}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500 text-slate-950 text-xs font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'start'
? <Loader2 className="h-4 w-4 animate-spin" />
: <PlayCircle className="h-4 w-4" />}
Start selected{selected.size ? ` (${selected.size})` : ''}
<ArrowRight className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleDismiss}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-ink-900/15 bg-white text-xs font-bold text-slate-600 hover:text-maroon-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'dismiss'
? <Loader2 className="h-4 w-4 animate-spin" />
: <Ban className="h-4 w-4" />}
Dismiss
</button>
<span className="text-[11px] text-slate-400 font-mono">
{pending} file(s) awaiting review
</span>
</div>
{/* THESE TWO GOVERN THE MANUAL START ABOVE, AND NOTHING ELSE.
An upload that ran itself never passed through here: POST
/api/uploads/catalog reads UPLOAD_AUTORUN_FETCH_IMAGES and
UPLOAD_AUTORUN_USE_LLM on the server and ignores whatever the
request asked for. What a given run actually used is on its own row
under Dagster Orchestration -> Recent runs. */}
<fieldset
disabled={stagesIdle}
className={`mt-4 rounded-xl border border-ink-900/10 bg-white p-3 transition ${
stagesIdle ? 'opacity-40' : ''
}`}
>
<legend className="px-1 text-[10px] font-bold uppercase tracking-wide text-slate-400">
Applies to Start selected
{stagesIdle && (
<span className="font-normal normal-case"> &mdash; tick a file below first</span>
)}
</legend>
<div className="flex flex-col gap-2">
<label
className={`flex items-start gap-2 text-[11px] text-slate-600 ${
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<input
type="checkbox"
checked={fetchImages}
disabled={stagesIdle}
onChange={(e) => setFetchImages(e.target.checked)}
className={`mt-0.5 accent-amber-500 ${
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
/>
<span>
<span className="font-bold text-ink-950">Search for product images</span>
{' '}&mdash; much slower, and makes outbound requests for every row.
</span>
</label>
<label
className={`flex items-start gap-2 text-[11px] text-slate-600 ${
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<input
type="checkbox"
checked={useLlm}
disabled={stagesIdle}
onChange={(e) => setUseLlm(e.target.checked)}
className={`mt-0.5 accent-amber-500 ${
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
/>
<span>
<span className="font-bold text-ink-950">Use the LLM for missing descriptions</span>
{' '}&mdash; fills blank descriptions where an Ollama server is reachable. It is not
in production, so rows there simply keep the description the sheet gave them.
</span>
</label>
</div>
</fieldset>
{notice && (
<div className="mt-4 rounded-lg bg-emerald-500/5 border border-emerald-500/20 p-3 text-xs text-emerald-700 flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" /> {notice}
</div>
)}
{error && (
<div className="mt-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600 flex items-start gap-2">
<XCircle className="h-4 w-4 shrink-0 mt-0.5" /> {error}
</div>
)}
</div>
{/* ---- the drops ------------------------------------------------ */}
{data && submissions.length === 0 && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Inbox className="h-8 w-8 text-slate-300 mx-auto mb-3" />
<p className="text-xs text-slate-500">
Nothing waiting &mdash; files sent to{' '}
<code className="font-mono">/api/uploads/catalog</code> start on their own and appear
under <strong>Dagster Orchestration &rarr; Recent runs</strong>.
</p>
<p className="text-[11px] text-slate-400 mt-2">
Set <code className="font-mono">UPLOAD_AUTORUN=false</code> to hold them here for a
decision instead.
</p>
</div>
)}
{submissions.map((submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.length > 0 && ids.every((id) => selected.has(id));
return (
<div
key={submission.submission_id}
className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs"
>
<div className="flex items-center justify-between gap-3 mb-4">
<h3 className="font-display text-sm font-bold text-ink-950">
from <span className="text-amber-600">{submission.submitted_by}</span>
<span className="ml-2 font-normal text-slate-400">
{ago(submission.created_at)} &middot; {submission.files.length} file(s)
</span>
</h3>
<button
type="button"
onClick={() => toggleDrop(submission)}
className="shrink-0 text-[11px] font-bold text-slate-500 hover:text-ink-950 transition cursor-pointer"
>
{allOn ? 'Clear all' : 'Select all'}
</button>
</div>
<ul className="space-y-1.5">
{submission.files.map((file) => (
<li key={file.file_id}>
<label className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-ink-900/10 bg-white cursor-pointer hover:border-amber-500/40 transition">
<input
type="checkbox"
checked={selected.has(file.file_id)}
onChange={() => toggle(file.file_id)}
className="accent-amber-500 cursor-pointer"
/>
<FileSpreadsheet className="h-4 w-4 text-amber-600 shrink-0" />
<span className="text-[11px] font-bold text-ink-950 truncate flex-1">
{file.filename}
</span>
<span className="font-mono text-[10px] text-slate-400 shrink-0">
{file.rows_total} rows
</span>
<span className="font-mono text-[10px] text-slate-300 shrink-0 w-16 text-right">
{fmtBytes(file.size_bytes)}
</span>
</label>
</li>
))}
</ul>
</div>
);
})}
{!data && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Loader2 className="h-5 w-5 animate-spin text-amber-500 mx-auto" />
</div>
)}
{pending > 0 && (
<p className="text-[11px] text-slate-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
Dismissing a file does not delete it from the server; it only takes it out of this
list so the count can reach zero. Staged files are purged automatically after a week.
</p>
)}
</div>
);
}

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import {
AlertTriangle, ArrowRight, CheckCircle2, Clock, FileSpreadsheet,
Loader2, MinusCircle, PlayCircle, RefreshCw, Workflow, XCircle,
Loader2, PlayCircle, RefreshCw, Workflow, XCircle,
} from 'lucide-react';
import { api } from '../api/client';
import { FILE_ICON, FILE_TONE, STAGE_FLAGS, ago, fmtBytes } from './pipelineFormat';
import { FileStages, Stat } from './pipelineShared';
/*
* Admin -> Dagster Orchestration.
@@ -40,55 +42,6 @@ const RUN_POLL_MS = 3000;
const TERMINAL = new Set(['done', 'failed', 'partial', 'cancelled']);
const FILE_TONE = {
done: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
failed: 'bg-maroon-100 text-maroon-600 border-maroon-500/30',
running: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
queued: 'bg-white text-slate-500 border-ink-900/10',
cancelled: 'bg-ink-100 text-slate-500 border-ink-900/10',
};
// The two optional network stages, rendered per run under Recent runs. A run
// that started on its own took these from the SERVER settings
// (UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM), never from the
// checkboxes on this page, so reading them back off the manifest is the only
// honest report of what a colleague's upload actually did.
const STAGE_FLAGS = [
{ key: 'fetch_images', label: 'img', title: 'Product image search' },
{ key: 'use_llm', label: 'llm', title: 'LLM description fill' },
];
const FILE_ICON = {
done: CheckCircle2,
failed: XCircle,
running: Loader2,
queued: Clock,
cancelled: MinusCircle,
};
function ago(seconds) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - seconds));
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function fmtBytes(n) {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function fmtSecs(from, to) {
if (!from || !to) return '';
const s = to - from;
if (s < 1) return '<1s';
if (s < 60) return `${s.toFixed(1)}s`;
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
}
export function OrchestrationPanel() {
const [inbox, setInbox] = useState(null);
const [recent, setRecent] = useState([]);
@@ -585,95 +538,3 @@ export function OrchestrationPanel() {
);
}
/* One file, and every stage of the pipeline as it happened to that file.
*
* All eleven are drawn from the moment the batch exists, greyed out until
* reached, so the shape of the pipeline is visible before anything runs and
* does not reflow as stages appear.
*
* A stage is looked up BY INDEX rather than by position in `file.stages`.
* Stages 8-11 run once per brand in the sheet, so the backend folds repeat
* visits into one record per index; matching on index is what keeps a
* three-brand file from appearing to run backwards. */
function FileStages({ file, stageNames }) {
const Icon = FILE_ICON[file.status] || Clock;
const byIndex = new Map((file.stages || []).map((s) => [s.index, s]));
const names = stageNames.length ? stageNames : (file.stages || []).map((s) => s.name);
return (
<li className={`rounded-xl border p-3 ${FILE_TONE[file.status] || FILE_TONE.queued}`}>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 min-w-0">
<Icon className={`h-3.5 w-3.5 shrink-0 ${file.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-[11px] font-bold truncate">{file.filename}</span>
</span>
<span className="flex items-center gap-2 shrink-0">
{file.started_at && file.finished_at && (
<span className="font-mono text-[10px] opacity-70">
{fmtSecs(file.started_at, file.finished_at)}
</span>
)}
<span className="font-mono text-[10px] uppercase">{file.status}</span>
</span>
</div>
{file.detail && <p className="mt-1.5 text-[10px] leading-relaxed">{file.detail}</p>}
{names.length > 0 && (
<ol className="mt-2.5 space-y-1">
{names.map((name, i) => {
const index = i + 1;
const record = byIndex.get(index);
const done = record && record.finished_at;
const active = record && !record.finished_at;
return (
<li
key={index}
className={`flex items-center gap-2 rounded-lg px-2 py-1 text-[10px] ${
done
? 'bg-white/60 text-slate-600'
: active
? 'bg-amber-500/15 text-amber-700 font-bold'
: 'text-slate-400'
}`}
>
<span className="font-mono w-7 shrink-0 text-right opacity-60">{index}/11</span>
{done && <CheckCircle2 className="h-3 w-3 shrink-0 text-leaf-600" />}
{active && <Loader2 className="h-3 w-3 shrink-0 animate-spin" />}
{!record && <Clock className="h-3 w-3 shrink-0 opacity-40" />}
<span className="truncate flex-1">{name}</span>
{record && record.rows_total > 0 && (
<span className="font-mono shrink-0 opacity-70">
{record.rows_done}/{record.rows_total}
</span>
)}
{done && (
<span className="font-mono shrink-0 w-12 text-right opacity-50">
{fmtSecs(record.started_at, record.finished_at)}
</span>
)}
</li>
);
})}
</ol>
)}
</li>
);
}
const TONES = {
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700',
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-700',
slate: 'border-ink-900/10 bg-white text-slate-600',
maroon: 'border-maroon-500/20 bg-maroon-100/40 text-maroon-600',
amber: 'border-amber-500/20 bg-amber-500/5 text-amber-700',
};
function Stat({ label, value, tone }) {
return (
<div className={`rounded-xl border p-3 ${TONES[tone] || TONES.slate}`}>
<p className="text-[10px] font-semibold uppercase">{label}</p>
<p className="font-mono text-2xl font-extrabold text-ink-950 mt-0.5">{value ?? 0}</p>
</div>
);
}

View File

@@ -0,0 +1,670 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AlertTriangle, Ban, CheckCircle2, ClipboardCheck, Clock, FileSpreadsheet,
Loader2, PlayCircle, RefreshCw, XCircle, ArrowRight,
} from 'lucide-react';
import { api } from '../api/client';
import { STAGE_FLAGS, ago, fmtBytes } from './pipelineFormat';
import { FileStages, Stat } from './pipelineShared';
/*
* Admin -> Upload Results.
*
* What happened to the last file a colleague sent. No picking, no clicking:
* the newest colleague upload is found and followed automatically, all eleven
* stages, down to which rows the validation gate refused and why.
*
* THIS TAB USED TO BE THE REVIEW INBOX, and the inbox is still here - see the
* gate section below. It is just no longer the point of the screen, because
* with UPLOAD_AUTORUN at its default of true a colleague's upload starts on
* arrival and never waits for review, so the inbox is permanently empty and the
* tab showed nothing but an empty state pointing at another tab.
*
* HOW THIS DIFFERS FROM DAGSTER ORCHESTRATION, which also draws stage
* timelines: that screen is for a run YOU launched - you tick files and watch
* the batch you started, including your own admin uploads. This one answers the
* opposite question, about a run nobody here started, and it is the only place
* that shows the per-row rejection reasons a sender needs to fix their sheet.
*
* WHICH RUN IS "THE COLLEAGUE'S". `submitted_by` is the discriminator and it is
* exact, not a heuristic: POST /api/uploads/catalog always sets it (falling back
* to the `sender` form label and then to "anonymous"), and the admin's own
* POST /api/admin/catalog-batch/ingest never passes it at all, so it is null
* there. A batch released from the inbox carries the original senders, which is
* correct - it is still their upload, just started by hand.
*/
// Two questions at two cadences. "Has anything new arrived?" is cheap and slow;
// "how far has the run I am watching got?" is the one that needs to feel live,
// and it stops itself at a terminal status so a tab left open overnight goes
// quiet. Both are cleared on unmount.
const DISCOVER_POLL_MS = 10000;
const RUN_POLL_MS = 3000;
// How far back to look for a colleague upload. The endpoint returns ALL runs
// newest-first, admin batches included, so a window of 20 could be filled by
// the admin's own uploads and hide the colleague's. Slim rows are not tiny -
// they keep stages[], rejections and errors - so ask for 50 on the steady-state
// tick and widen to the endpoint's ceiling only when nothing was found.
const DISCOVER_LIMIT = 50;
const DISCOVER_MAX_LIMIT = 100;
// When to stop following a run. Wider than the Orchestration tab's set
// (OrchestrationPanel.jsx), which omits both `retired` and `interrupted`.
//
// `interrupted` is not terminal on the backend - a Resume can still move it -
// but nothing will move it on its own, so a 3s poll would hammer a batch that
// cannot change until an operator acts. Park on it; the 10s discovery tick
// notices if BATCH_AUTO_RESUME re-queues it after a restart.
const SETTLED = new Set([
'done', 'failed', 'partial', 'cancelled', 'retired', 'interrupted',
]);
const STATUS_ICON = {
done: CheckCircle2,
partial: AlertTriangle,
failed: XCircle,
running: Loader2,
interrupted: AlertTriangle,
};
const STATUS_TONE = {
done: 'text-leaf-600',
partial: 'text-amber-600',
failed: 'text-maroon-600',
running: 'text-amber-600',
interrupted: 'text-amber-600',
};
/* Is this row a colleague's upload, and does it have anything to show?
*
* `submitted_by` alone is not enough. GET /batches drops only PENDING rows
* (batch_catalog.py), so a drop that was dismissed outright survives the filter
* as a RETIRED husk - it still carries the sender's name, but every file was
* discarded, so it has no result and no stage timeline. Without the second
* clause, dismissing a drop makes that husk "the latest upload" and the panel
* renders eleven grey rows that will never fill in.
*
* A drop that was RELEASED rather than dismissed is harmless either way: the
* run it produced is staged before the drop is retired, so the run is newer and
* wins the ordering regardless. */
function isColleagueUpload(batch) {
return (
typeof batch?.submitted_by === 'string' &&
batch.submitted_by.trim() !== '' &&
batch.status !== 'retired'
);
}
function IssueList({ title, items, tone = 'slate' }) {
if (!items.length) return null;
const border =
tone === 'maroon' ? 'border-maroon-500/25 bg-maroon-100/30' : 'border-ink-900/10 bg-white';
return (
<div className={`mt-4 rounded-xl border p-3 ${border}`}>
<p className="text-[10px] font-bold uppercase tracking-wide text-slate-500 mb-1.5">
{title} <span className="font-mono opacity-60">({items.length})</span>
</p>
<ul className="space-y-1">
{items.map((text, i) => (
<li key={i} className="text-[11px] leading-relaxed text-slate-600">
{text}
</li>
))}
</ul>
</div>
);
}
export function UploadResultsPanel({ onBatchStarted }) {
// --- the results half ---------------------------------------------------
const [run, setRun] = useState(null);
const [discovered, setDiscovered] = useState(false); // first discovery done
const runIdRef = useRef(null);
// Whether the widened lookup has already been spent - see discover().
const escalatedRef = useRef(false);
// --- the gate half (only rendered when something is actually waiting) ----
const [inbox, setInbox] = useState(null);
const [selected, setSelected] = useState(() => new Set());
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [useLlm, setUseLlm] = useState(true);
const [fetchImages, setFetchImages] = useState(true);
/* Find the newest colleague upload, and read the inbox in the same tick.
*
* A failed poll is swallowed rather than replacing the panel with an error:
* a transient blip must not wipe a run the admin is reading, and the next
* tick retries. Same reasoning as the badge poll in AdminPage. */
const discover = useCallback(async () => {
try {
let { batches } = await api.listCatalogBatches(DISCOVER_LIMIT);
let latest = (batches || []).find(isColleagueUpload);
// Nothing in the window. Before concluding there is no colleague upload,
// widen once to the endpoint's ceiling - a burst of admin batches can push
// a real one past 50. Guarded so it happens at most once per mount (and
// once per manual Refresh), never on the steady-state path where a run is
// already on screen.
if (!latest && !escalatedRef.current) {
escalatedRef.current = true;
({ batches } = await api.listCatalogBatches(DISCOVER_MAX_LIMIT));
latest = (batches || []).find(isColleagueUpload);
}
if (latest && latest.batch_id !== runIdRef.current) {
runIdRef.current = latest.batch_id;
// Show the slim row immediately - it already carries stages[] and the
// counters, because slim strips only result.products - then let the
// detail poll replace it with the full record.
setRun(latest);
} else if (!latest) {
runIdRef.current = null;
setRun(null);
}
} catch {
/* next tick retries */
}
try {
setInbox(await api.listInbox());
} catch {
/* next tick retries */
}
setDiscovered(true);
}, []);
useEffect(() => {
discover();
const timer = setInterval(discover, DISCOVER_POLL_MS);
return () => clearInterval(timer);
}, [discover]);
// Follow the watched run until it settles. Re-created whenever the id or the
// status changes, so it stops itself the moment the run settles rather than
// polling a finished batch forever.
const runId = run?.batch_id;
const runStatus = run?.status;
useEffect(() => {
if (!runId || SETTLED.has(runStatus)) return undefined;
let alive = true;
const tick = async () => {
try {
const full = await api.getCatalogBatch(runId);
if (alive && full?.batch_id === runIdRef.current) setRun(full);
} catch {
/* next tick retries */
}
};
tick();
const timer = setInterval(tick, RUN_POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [runId, runStatus]);
// A run that was already settled when we adopted it never enters the poll
// above, so fetch its full record once. A run that settles while being
// followed already had its last fetch from that poll.
useEffect(() => {
if (!runId || !SETTLED.has(runStatus)) return undefined;
let alive = true;
(async () => {
try {
const full = await api.getCatalogBatch(runId);
if (alive && full?.batch_id === runIdRef.current) setRun(full);
} catch {
/* the slim row is already on screen; it is enough */
}
})();
return () => {
alive = false;
};
// Deliberately keyed on the id alone: re-running this on every status
// change would re-fetch a settled batch for no reason.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId]);
// --- gate actions (unchanged behaviour, just conditionally rendered) -----
const toggle = (fileId) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(fileId)) next.delete(fileId);
else next.add(fileId);
return next;
});
};
const toggleDrop = (submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.every((id) => selected.has(id));
setSelected((current) => {
const next = new Set(current);
ids.forEach((id) => (allOn ? next.delete(id) : next.add(id)));
return next;
});
};
const handleStart = async () => {
if (!selected.size) return;
setBusy('start');
setError('');
setNotice('');
try {
const batch = await api.startBatchFromInbox([...selected], {
use_llm: useLlm,
fetch_images: fetchImages,
});
setSelected(new Set());
setNotice(
`Started ${batch.files_total} file(s) as one batch. Follow it on the ` +
`Batch Catalog Ingestion tab.`
);
await discover();
if (onBatchStarted) onBatchStarted(batch);
} catch (err) {
setError(err?.message || 'Could not start those files.');
await discover();
} finally {
setBusy('');
}
};
const handleDismiss = async () => {
if (!selected.size) return;
setBusy('dismiss');
setError('');
setNotice('');
try {
const result = await api.dismissInboxFiles([...selected]);
setSelected(new Set());
setNotice(`Dismissed ${result.dismissed} file(s). They will not be processed.`);
await discover();
} catch (err) {
setError(err?.message || 'Could not dismiss those files.');
await discover();
} finally {
setBusy('');
}
};
// Drop any selection that is no longer pending - another tab may have started
// or dismissed it, and a checkbox pointing at a file that is already gone
// would 409 on the next click with no explanation.
useEffect(() => {
if (!inbox) return;
const live = new Set(inbox.submissions.flatMap((s) => s.files.map((f) => f.file_id)));
setSelected((current) => new Set([...current].filter((id) => live.has(id))));
}, [inbox]);
const pending = inbox?.pending_count ?? 0;
const submissions = inbox?.submissions || [];
const totals = run?.totals;
const files = run?.files || [];
const rejections = files.flatMap((f) =>
(f.result?.rejections || []).map((r) => {
const where = r.row == null ? 'row unknown' : `row ${r.row}`;
const size = r.size ? ` ${r.size}` : '';
return `${where} · ${r.product_name || '(unnamed)'}${size} — ${r.reason}`;
})
);
const rowErrors = files.flatMap((f) =>
(f.result?.errors || []).map(
(e) => `row ${e.row} · ${e.product_name || '(unnamed)'} — ${e.error}`
)
);
const warnings = files.flatMap((f) => (f.result?.warnings || []).map((w) => w));
const storageErrors = files
.filter((f) => f.result?.storage_error)
.map((f) => `${f.filename} — ${f.result.storage_error}`);
const StatusIcon = STATUS_ICON[run?.status] || Clock;
return (
<div className="space-y-6">
{/* ---- the review gate, only when something is actually waiting ------
With UPLOAD_AUTORUN on this never renders. Turn the setting off and
it comes back on its own, which is the behaviour settings.py promises
- "the review inbox comes back with no code change". */}
{pending > 0 && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-6 shadow-xs">
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<AlertTriangle className="h-5 w-5 text-amber-600" /> Waiting for review
</h2>
<p className="text-xs text-slate-600 leading-relaxed max-w-3xl">
<code className="font-mono">UPLOAD_AUTORUN</code> is off, so these have been held
rather than run. Tick what you want and press <strong>Start selected</strong> to send
them through the 11-stage pipeline as one batch. Files from different drops can go in
the same batch.
</p>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleStart}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500 text-slate-950 text-xs font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'start'
? <Loader2 className="h-4 w-4 animate-spin" />
: <PlayCircle className="h-4 w-4" />}
Start selected{selected.size ? ` (${selected.size})` : ''}
<ArrowRight className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleDismiss}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-ink-900/15 bg-white text-xs font-bold text-slate-600 hover:text-maroon-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'dismiss'
? <Loader2 className="h-4 w-4 animate-spin" />
: <Ban className="h-4 w-4" />}
Dismiss
</button>
<span className="text-[11px] text-slate-500 font-mono">
{pending} file(s) awaiting review
</span>
</div>
{/* These govern the manual start above and nothing else. An upload
that ran itself never passed through here - the server reads
UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM and ignores
whatever a request asked for. What a given run actually used is
the img/llm pair on its own header below. */}
<fieldset className="mt-4 rounded-xl border border-ink-900/10 bg-white p-3">
<legend className="px-1 text-[10px] font-bold uppercase tracking-wide text-slate-400">
Applies to Start selected
</legend>
<div className="flex flex-col gap-2">
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={fetchImages}
onChange={(e) => setFetchImages(e.target.checked)}
className="mt-0.5 accent-amber-500 cursor-pointer"
/>
<span>
<span className="font-bold text-ink-950">Search for product images</span>
{' '}&mdash; much slower, and makes outbound requests for every row.
</span>
</label>
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={useLlm}
onChange={(e) => setUseLlm(e.target.checked)}
className="mt-0.5 accent-amber-500 cursor-pointer"
/>
<span>
<span className="font-bold text-ink-950">Use the LLM for missing descriptions</span>
{' '}&mdash; fills blank descriptions where an Ollama server is reachable.
</span>
</label>
</div>
</fieldset>
{notice && (
<div className="mt-4 rounded-lg bg-emerald-500/5 border border-emerald-500/20 p-3 text-xs text-emerald-700 flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" /> {notice}
</div>
)}
{error && (
<div className="mt-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600 flex items-start gap-2">
<XCircle className="h-4 w-4 shrink-0 mt-0.5" /> {error}
</div>
)}
<div className="mt-4 space-y-3">
{submissions.map((submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.length > 0 && ids.every((id) => selected.has(id));
return (
<div
key={submission.submission_id}
className="rounded-xl border border-ink-900/10 bg-white p-4"
>
<div className="flex items-center justify-between gap-3 mb-3">
<h3 className="font-display text-xs font-bold text-ink-950">
from <span className="text-amber-600">{submission.submitted_by}</span>
<span className="ml-2 font-normal text-slate-400">
{ago(submission.created_at)} &middot; {submission.files.length} file(s)
</span>
</h3>
<button
type="button"
onClick={() => toggleDrop(submission)}
className="shrink-0 text-[11px] font-bold text-slate-500 hover:text-ink-950 transition cursor-pointer"
>
{allOn ? 'Clear all' : 'Select all'}
</button>
</div>
<ul className="space-y-1.5">
{submission.files.map((file) => (
<li key={file.file_id}>
<label className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-ink-900/10 bg-paper-50 cursor-pointer hover:border-amber-500/40 transition">
<input
type="checkbox"
checked={selected.has(file.file_id)}
onChange={() => toggle(file.file_id)}
className="accent-amber-500 cursor-pointer"
/>
<FileSpreadsheet className="h-4 w-4 text-amber-600 shrink-0" />
<span className="text-[11px] font-bold text-ink-950 truncate flex-1">
{file.filename}
</span>
<span className="font-mono text-[10px] text-slate-400 shrink-0">
{file.rows_total} rows
</span>
<span className="font-mono text-[10px] text-slate-300 shrink-0 w-16 text-right">
{fmtBytes(file.size_bytes)}
</span>
</label>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
)}
{/* ---- what the page is for ---------------------------------------- */}
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<ClipboardCheck className="h-5 w-5 text-amber-500" /> Upload Results
</h2>
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
The most recent spreadsheet a colleague sent to{' '}
<code className="font-mono">/api/uploads/catalog</code>, and everything the
11-stage pipeline did with it &mdash; including which rows were refused and why.
It updates on its own; there is nothing to pick. Your own uploads live under{' '}
<strong>Batch Catalog Ingestion</strong>.
</p>
</div>
<button
type="button"
onClick={() => {
// An explicit Refresh buys back the widened lookup, so an admin
// who knows a colleague just uploaded can force the deep search.
escalatedRef.current = false;
discover();
}}
aria-label="Check for a newer upload"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-ink-950 transition cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5" /> Refresh
</button>
</div>
</div>
{/* ---- the run ------------------------------------------------------ */}
{!discovered && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Loader2 className="h-5 w-5 animate-spin text-amber-500 mx-auto" />
</div>
)}
{/* Gate open but nothing has run: "no upload yet" would be flatly false -
there is one, it is sitting above waiting for a decision. */}
{discovered && !run && pending > 0 && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Clock className="h-8 w-8 text-slate-300 mx-auto mb-3" />
<p className="text-xs text-slate-500">
Nothing has run yet &mdash; the upload above is waiting for your approval. Tick the
files and press <strong>Start selected</strong>, and its stage breakdown appears here.
</p>
</div>
)}
{discovered && !run && pending === 0 && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<ClipboardCheck className="h-8 w-8 text-slate-300 mx-auto mb-3" />
<p className="text-xs text-slate-500">
No colleague upload yet. The moment one arrives at{' '}
<code className="font-mono">POST /api/uploads/catalog</code>, its run appears here
with the full stage breakdown.
</p>
<p className="text-[11px] text-slate-400 mt-2">
Files you upload yourself are not shown here &mdash; they are under{' '}
<strong>Batch Catalog Ingestion</strong>. Uploads older than a week are cleared by
retention.
</p>
</div>
)}
{run && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-start justify-between gap-3 mb-1">
<h3 className="font-display text-sm font-bold text-ink-950 flex items-center gap-2">
<StatusIcon
className={`h-4 w-4 ${STATUS_TONE[run.status] || 'text-slate-400'} ${
run.status === 'running' ? 'animate-spin' : ''
}`}
/>
from <span className="text-amber-600">{run.submitted_by}</span>
<span className="font-normal text-slate-400">
{ago(run.created_at)} &middot; {run.files_total} file(s)
</span>
</h3>
<div className="flex items-center gap-2 shrink-0">
{STAGE_FLAGS.map(({ key, label, title }) => (
<span
key={key}
title={title}
className={`rounded-full px-2 py-0.5 text-[10px] font-bold border ${
run[key]
? 'bg-amber-500/15 border-amber-500/30 text-amber-600'
: 'bg-white border-ink-900/10 text-slate-300 line-through'
}`}
>
{label}
</span>
))}
<span className="font-mono text-[11px] uppercase text-slate-400">{run.status}</span>
</div>
</div>
<p className="font-mono text-[10px] text-slate-400 mb-4">{run.batch_id}</p>
{/* `partial` exists so this does not read as success. Some files
landed and some did not, and the counters below are the total of
only the ones that did. */}
{run.status === 'partial' && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
{run.files_done} of {run.files_total} file(s) landed; {run.files_failed} did not.
The counters below cover only what succeeded.
</span>
</div>
)}
{run.status === 'queued' && (
<div className="mb-4 rounded-lg bg-white border border-ink-900/10 p-3 text-[11px] text-slate-500 flex items-start gap-2">
<Clock className="h-4 w-4 shrink-0 mt-0.5" />
<span>
{run.runner === 'dagster'
? 'Staged and waiting for the Dagster orchestrator to claim it. Dagster is not deployed in production, so this will wait indefinitely there.'
: 'Staged and waiting its turn - the ingestion queue was busy when this arrived.'}
</span>
</div>
)}
{run.status === 'interrupted' && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
The backend restarted while this was running, so its unfinished files were put
back in the queue. Resume it from <strong>Batch Catalog Ingestion</strong>.
</span>
</div>
)}
{run.detail && (
<p className="mb-4 text-[11px] text-slate-500 leading-relaxed">{run.detail}</p>
)}
{/* A storage error still leaves rows built, so it hides among the
counters - but it is what forces the file to failed, so it is
called out on its own. */}
{storageErrors.length > 0 && (
<div className="mb-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-[11px] text-maroon-600">
<p className="font-bold mb-1">Could not write to the catalogue</p>
{storageErrors.map((text, i) => (
<p key={i} className="leading-relaxed">{text}</p>
))}
</div>
)}
<ul className="space-y-3">
{files.map((file) => (
<FileStages
key={`${file.index}-${file.filename}`}
file={file}
stageNames={run.stage_names || []}
/>
))}
</ul>
{/* Gated on something having landed, so a still-queued run shows the
stage list rather than a wall of zeros. */}
{totals && (run.files_done > 0 || run.files_failed > 0) && (
<div className="mt-5 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<Stat label="Rows read" value={totals.rows_total} tone="slate" />
<Stat label="Products built" value={totals.products_built} tone="blue" />
<Stat label="Inserted" value={totals.inserted} tone="emerald" />
<Stat label="Backfilled" value={totals.backfilled} tone="amber" />
<Stat label="Unchanged" value={totals.skipped_existing} tone="slate" />
<Stat label="Rejected" value={totals.rejected} tone="maroon" />
</div>
)}
{run.brands?.length > 0 && (
<p className="mt-4 text-[11px] text-slate-500">
Brands touched:{' '}
<span className="font-bold text-ink-950">{run.brands.join(', ')}</span>
</p>
)}
{/* The reason this screen exists rather than sending people to the
Dagster tab: a refused row, named, with the sheet line number the
sender sees on screen (header counted as row 1). */}
<IssueList
title="Refused by the validation gate"
items={rejections}
tone="maroon"
/>
<IssueList title="Rows that could not be read" items={rowErrors} />
<IssueList title="Warnings" items={warnings} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { CheckCircle2, Clock, Loader2, MinusCircle, XCircle } from 'lucide-react';
/*
* Constants and formatters shared by the two ingestion screens.
*
* Kept apart from pipelineShared.jsx, which holds the components: a module that
* exports both breaks React Fast Refresh, which oxlint flags.
*
* These lived inside OrchestrationPanel until UploadResultsPanel needed the
* same timeline. Copying them again was the alternative - the old InboxPanel
* and OrchestrationPanel already carried duplicate `ago`/`fmtBytes`, and
* BatchCatalogPanel still carries its own `Stat` - and a second copy of
* FileStages in particular is the one worth avoiding, because its stage lookup
* is subtle (see the comment on the component).
*
* Moved verbatim. Nothing here changed behaviour when it was lifted; if this
* file and the Orchestration screen ever disagree, this file is wrong.
*/
export const FILE_TONE = {
done: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
failed: 'bg-maroon-100 text-maroon-600 border-maroon-500/30',
running: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
queued: 'bg-white text-slate-500 border-ink-900/10',
cancelled: 'bg-ink-100 text-slate-500 border-ink-900/10',
};
export const FILE_ICON = {
done: CheckCircle2,
failed: XCircle,
running: Loader2,
queued: Clock,
cancelled: MinusCircle,
};
// The two optional network stages, read back off the manifest. A run that
// started on its own took these from the SERVER settings
// (UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM), never from the
// checkboxes on any page, so the manifest is the only honest report of what a
// colleague's upload actually did.
export const STAGE_FLAGS = [
{ key: 'fetch_images', label: 'img', title: 'Product image search' },
{ key: 'use_llm', label: 'llm', title: 'LLM description fill' },
];
export function ago(seconds) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - seconds));
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
export function fmtBytes(n) {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function fmtSecs(from, to) {
if (!from || !to) return '';
const s = to - from;
if (s < 1) return '<1s';
if (s < 60) return `${s.toFixed(1)}s`;
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
}
// Stat tile tones. Semantic, not decorative: emerald for rows that landed,
// maroon for rows that did not.
export const TONES = {
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700',
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-700',
slate: 'border-ink-900/10 bg-white text-slate-600',
maroon: 'border-maroon-500/20 bg-maroon-100/40 text-maroon-600',
amber: 'border-amber-500/20 bg-amber-500/5 text-amber-700',
};

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { CheckCircle2, Clock, Loader2 } from 'lucide-react';
import { FILE_ICON, FILE_TONE, TONES, fmtSecs } from './pipelineFormat';
/*
* The two pieces of a batch run both ingestion screens draw: the eleven-stage
* timeline and the stat tile. Constants and formatters live in
* pipelineFormat.js so this module exports components only.
*
* Moved verbatim out of OrchestrationPanel when UploadResultsPanel needed the
* same timeline. Nothing changed behaviour in the move; if this file and the
* Orchestration screen ever disagree, this file is wrong.
*/
/* One file, and every stage of the pipeline as it happened to that file.
*
* Renders an <li>, so the caller must wrap it in a list.
*
* All eleven are drawn from the moment the batch exists, greyed out until
* reached, so the shape of the pipeline is visible before anything runs and
* does not reflow as stages appear.
*
* A stage is looked up BY INDEX rather than by position in `file.stages`.
* Stages 8-11 run once per brand in the sheet, so the backend folds repeat
* visits into one record per index; matching on index is what keeps a
* three-brand file from appearing to run backwards. */
export function FileStages({ file, stageNames }) {
const Icon = FILE_ICON[file.status] || Clock;
const byIndex = new Map((file.stages || []).map((s) => [s.index, s]));
const names = stageNames.length ? stageNames : (file.stages || []).map((s) => s.name);
return (
<li className={`rounded-xl border p-3 ${FILE_TONE[file.status] || FILE_TONE.queued}`}>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 min-w-0">
<Icon className={`h-3.5 w-3.5 shrink-0 ${file.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-[11px] font-bold truncate">{file.filename}</span>
</span>
<span className="flex items-center gap-2 shrink-0">
{file.started_at && file.finished_at && (
<span className="font-mono text-[10px] opacity-70">
{fmtSecs(file.started_at, file.finished_at)}
</span>
)}
<span className="font-mono text-[10px] uppercase">{file.status}</span>
</span>
</div>
{file.detail && <p className="mt-1.5 text-[10px] leading-relaxed">{file.detail}</p>}
{names.length > 0 && (
<ol className="mt-2.5 space-y-1">
{names.map((name, i) => {
const index = i + 1;
const record = byIndex.get(index);
const done = record && record.finished_at;
const active = record && !record.finished_at;
return (
<li
key={index}
className={`flex items-center gap-2 rounded-lg px-2 py-1 text-[10px] ${
done
? 'bg-white/60 text-slate-600'
: active
? 'bg-amber-500/15 text-amber-700 font-bold'
: 'text-slate-400'
}`}
>
<span className="font-mono w-7 shrink-0 text-right opacity-60">{index}/11</span>
{done && <CheckCircle2 className="h-3 w-3 shrink-0 text-leaf-600" />}
{active && <Loader2 className="h-3 w-3 shrink-0 animate-spin" />}
{!record && <Clock className="h-3 w-3 shrink-0 opacity-40" />}
<span className="truncate flex-1">{name}</span>
{record && record.rows_total > 0 && (
<span className="font-mono shrink-0 opacity-70">
{record.rows_done}/{record.rows_total}
</span>
)}
{done && (
<span className="font-mono shrink-0 w-12 text-right opacity-50">
{fmtSecs(record.started_at, record.finished_at)}
</span>
)}
</li>
);
})}
</ol>
)}
</li>
);
}
export function Stat({ label, value, tone }) {
return (
<div className={`rounded-xl border p-3 ${TONES[tone] || TONES.slate}`}>
<p className="text-[10px] font-semibold uppercase">{label}</p>
<p className="font-mono text-2xl font-extrabold text-ink-950 mt-0.5">{value ?? 0}</p>
</div>
);
}