diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index c207b1e..8aa16c6 100644
--- a/src/pages/AdminPage.jsx
+++ b/src/pages/AdminPage.jsx
@@ -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' && }
{activeTab === 'inbox' && (
- {
setAdoptedBatch(batch);
setActiveTab('batch');
diff --git a/src/pages/BatchCatalogPanel.jsx b/src/pages/BatchCatalogPanel.jsx
index ac631de..6b84ea6 100644
--- a/src/pages/BatchCatalogPanel.jsx
+++ b/src/pages/BatchCatalogPanel.jsx
@@ -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(() => {
diff --git a/src/pages/InboxPanel.jsx b/src/pages/InboxPanel.jsx
deleted file mode 100644
index 990b7b8..0000000
--- a/src/pages/InboxPanel.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
- Review Inbox
-
-
- Where spreadsheets wait when UPLOAD_AUTORUN
- {' '}is off. Nothing here has run yet — tick the files you want and
- press Start selected 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{' '}
- Dagster Orchestration instead.
-
-
-
-
-
-
-
-
-
-
-
- {pending} file(s) awaiting review
-
-
-
- {/* 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. */}
-
-
- {notice && (
-
-
- 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.
-
- )}
-
- );
-}
diff --git a/src/pages/OrchestrationPanel.jsx b/src/pages/OrchestrationPanel.jsx
index 32d73c9..ed059b5 100644
--- a/src/pages/OrchestrationPanel.jsx
+++ b/src/pages/OrchestrationPanel.jsx
@@ -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 (
-
- );
-}
diff --git a/src/pages/UploadResultsPanel.jsx b/src/pages/UploadResultsPanel.jsx
new file mode 100644
index 0000000..4d3588f
--- /dev/null
+++ b/src/pages/UploadResultsPanel.jsx
@@ -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 (
+
+
+ {title} ({items.length})
+
+
+ {items.map((text, i) => (
+
+ {text}
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+ {/* ---- 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 && (
+
+
+ Waiting for review
+
+
+ UPLOAD_AUTORUN is off, so these have been held
+ rather than run. Tick what you want and press Start selected to send
+ them through the 11-stage pipeline as one batch. Files from different drops can go in
+ the same batch.
+
+
+
+
+
+
+
+
+ {pending} file(s) awaiting review
+
+
+
+ {/* 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. */}
+
+
+ {notice && (
+
+ )}
+
+ {/* ---- what the page is for ---------------------------------------- */}
+
+
+
+
+ Upload Results
+
+
+ The most recent spreadsheet a colleague sent to{' '}
+ /api/uploads/catalog, and everything the
+ 11-stage pipeline did with it — including which rows were refused and why.
+ It updates on its own; there is nothing to pick. Your own uploads live under{' '}
+ Batch Catalog Ingestion.
+
+
+
+
+
+
+ {/* ---- the run ------------------------------------------------------ */}
+ {!discovered && (
+
+
+
+ )}
+
+ {/* 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 && (
+
+
+
+ Nothing has run yet — the upload above is waiting for your approval. Tick the
+ files and press Start selected, and its stage breakdown appears here.
+
+ No colleague upload yet. The moment one arrives at{' '}
+ POST /api/uploads/catalog, its run appears here
+ with the full stage breakdown.
+
+
+ Files you upload yourself are not shown here — they are under{' '}
+ Batch Catalog Ingestion. Uploads older than a week are cleared by
+ retention.
+
+
+ {/* `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' && (
+
+
+
+ {run.files_done} of {run.files_total} file(s) landed; {run.files_failed} did not.
+ The counters below cover only what succeeded.
+
+
+ )}
+
+ {run.status === 'queued' && (
+
+
+
+ {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.'}
+
+
+ )}
+
+ {run.status === 'interrupted' && (
+
+
+
+ The backend restarted while this was running, so its unfinished files were put
+ back in the queue. Resume it from Batch Catalog Ingestion.
+
+
+ )}
+
+ {run.detail && (
+
{run.detail}
+ )}
+
+ {/* 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 && (
+
+
Could not write to the catalogue
+ {storageErrors.map((text, i) => (
+
{text}
+ ))}
+
+ )}
+
+
+ {files.map((file) => (
+
+ ))}
+
+
+ {/* 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) && (
+
+ )}
+
+ {/* 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). */}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/pipelineFormat.js b/src/pages/pipelineFormat.js
new file mode 100644
index 0000000..c1b2098
--- /dev/null
+++ b/src/pages/pipelineFormat.js
@@ -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',
+};
diff --git a/src/pages/pipelineShared.jsx b/src/pages/pipelineShared.jsx
new file mode 100644
index 0000000..d0d6fae
--- /dev/null
+++ b/src/pages/pipelineShared.jsx
@@ -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
, 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 (
+