Files
catalogue_frontend/src/pages/UploadResultsPanel.jsx
2026-09-07 12:37:25 +05:30

784 lines
36 KiB
JavaScript

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 { ago, fmtBytes } from './pipelineFormat';
import { FileStages } 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.
*
* THREE THINGS, IN THIS ORDER: the file's NAME, its ELEVEN STAGES, and the ROWS
* THAT FAILED. That ordering is the brief, and it is why the run counters, the
* "brands touched" line, the img/llm flag chips and the batch id were taken out
* - each was true, none was the question being asked, and together they pushed
* the filename down to eleven-point text in the middle of the card. What is
* left besides those three is the handful of banners (queued, interrupted,
* partial, storage error) that exist to explain a timeline which is entirely
* grey; without them an interrupted run reads as a broken screen rather than as
* a run waiting for a Resume.
*
* There is no history here and there is not meant to be one: exactly one run is
* ever on screen, the newest, replaced in place as newer ones arrive.
*
* 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.
*
* IT IS A PREFERENCE, NOT A GATE. It used to be a gate, and that made this tab
* a dead end: a colleague who walked over and used the Batch Catalog Ingestion
* tab instead of the API produced a run with a null `submitted_by`, which was
* filtered out permanently however recent it was, and the screen said "No
* colleague upload yet" - true of the field and false of the world. So when the
* window holds no colleague upload we now adopt the newest run of any origin
* and SAY SO, rather than showing nothing. The exactness above is what makes
* that label honest: a null sender means the Admin UI started it.
*
* AND A FAILED LOOKUP IS NOT AN EMPTY ONE. Every discovery error used to be
* swallowed into that same empty state, so an expired session, a 403, a dead
* backend and a genuinely quiet week were one indistinguishable sentence.
* `discoverError` now separates them. An error still never wipes a run already
* on screen - a blip must not blank a report someone is reading - it annotates
* it instead.
*/
// 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'
);
}
/* The fallback: any run at all, as long as it is not a retired husk.
*
* Same second clause as above and for the same reason - a dismissed drop keeps
* its row but has no stages and no result, so adopting one would draw eleven
* grey rows that can never fill in. `pending` needs no clause here: GET
* /batches drops those server-side (batch_catalog.py). */
function isShowableRun(batch) {
return Boolean(batch) && 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
// Why the panel is empty, when it is. '' means the last lookup succeeded.
const [discoverError, setDiscoverError] = useState('');
// How many runs that lookup examined, so an empty screen can say "looked at
// 40 runs, none of them showable" rather than an unqualified "nothing".
const [scanned, setScanned] = useState(0);
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 run to show, and read the inbox in the same tick.
*
* A colleague upload is preferred; failing that, the newest run of any
* origin, so the tab is never a dead end. See the header comment.
*
* A failed poll never replaces a run already on screen - a transient blip
* must not wipe a report the admin is reading, and the next tick retries.
* But it IS recorded in `discoverError`, and the two renderings differ: with
* a run up it is a staleness strip, with nothing up it is the whole card.
* Silently treating a failure as "no uploads" is the bug this tab had.
*
* The inbox read is kept separate and stays silent. It is the secondary half
* of the screen and its failure must not blank the results. */
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);
}
// No colleague upload in reach. Show the newest run there is rather than
// an empty screen - see the header comment. The label that goes with it is
// derived at render from `submitted_by`, which is exact.
const chosen = latest || (batches || []).find(isShowableRun) || null;
setScanned((batches || []).length);
setDiscoverError('');
if (chosen && chosen.batch_id !== runIdRef.current) {
runIdRef.current = chosen.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(chosen);
} else if (!chosen) {
runIdRef.current = null;
setRun(null);
}
} catch (err) {
// Deliberately NOT clearing `run`: a transient blip must not wipe a
// report someone is reading. The strip below says it may be stale.
setDiscoverError(err?.message || 'Could not read the list of ingestion runs.');
}
try {
setInbox(await api.listInbox());
} catch {
/* next tick retries */
}
setDiscovered(true);
}, []);
useEffect(() => {
discover();
const timer = setInterval(discover, DISCOVER_POLL_MS);
return () => clearInterval(timer);
}, [discover]);
// An explicit retry buys back the widened lookup, so an admin who knows a
// colleague just uploaded can force the deep search. Shared by the header's
// Refresh button and the error card below, which must behave identically.
const retry = useCallback(() => {
escalatedRef.current = false;
discover();
}, [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 || [];
// Exact, not a guess: only POST /api/admin/catalog-batch/ingest leaves the
// sender null - uploads.py always sets one, and a release from the inbox
// carries the original senders through.
const fromColleague = isColleagueUpload(run);
const files = run?.files || [];
/* The answer to "what did they send?", promoted to the headline.
*
* A drop can carry several files and each still gets its own named row in the
* timeline below, so naming the first and counting the rest loses nothing.
* `current_file` is the fallback for the window where a batch exists but its
* file list has not been rendered yet. */
const headlineName = files[0]?.filename || run?.current_file || 'file name unavailable';
const extraFiles = Math.max(0, files.length - 1);
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. When no colleague upload is in
reach it falls back to the most recent run of any kind, and says so, rather than
showing you an empty screen.
</p>
</div>
<button
type="button"
onClick={retry}
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>
)}
{/* Nothing on screen AND the lookup failed. This is the case that used to
render as "No colleague upload yet", sending people to look for an
upload that had arrived perfectly well - the request for it 401'd. */}
{discovered && !run && discoverError && (
<div className="rounded-2xl border border-maroon-500/30 bg-maroon-100/30 p-10 text-center">
<XCircle className="h-8 w-8 text-maroon-600/60 mx-auto mb-3" />
<p className="text-xs font-bold text-maroon-600">Could not read the ingestion runs</p>
<p className="mt-1.5 font-mono text-[11px] text-maroon-600">{discoverError}</p>
<p className="mt-3 max-w-md mx-auto text-[11px] leading-relaxed text-slate-500">
This is not the same as having no uploads &mdash; nothing has been checked. If that
reads as expired or forbidden, sign in again; otherwise the backend is unreachable.
</p>
<button
type="button"
onClick={retry}
className="mt-4 inline-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" /> Try again
</button>
</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 && !discoverError && 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>
)}
{/* Genuinely nothing to show - and it now says WHICH nothing. With the
any-origin fallback in discover(), `scanned > 0` survives only for the
case where every run in reach is a retired husk. */}
{discovered && !run && !discoverError && 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" />
{scanned === 0 ? (
<p className="text-xs text-slate-500">
No ingestion run at all yet. The moment a colleague sends a sheet to{' '}
<code className="font-mono">POST /api/uploads/catalog</code>, its run appears here
with the full stage breakdown.
</p>
) : (
<p className="text-xs text-slate-500">
Looked at the last {scanned} ingestion run(s) and none can be shown &mdash; every
one of them was dismissed from the review inbox.
</p>
)}
<p className="text-[11px] text-slate-400 mt-2">
Only the most recent {DISCOVER_MAX_LIMIT} runs are searched, and 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">
{/* What is below is whatever the last SUCCESSFUL lookup found. Saying
so is the point - showing it silently as live is how a dead backend
passed for a quiet one. */}
{discoverError && (
<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 last refresh failed ({discoverError}), so this may be out of date. Retrying
every {Math.round(DISCOVER_POLL_MS / 1000)}s.
</span>
</div>
)}
{/* No colleague upload was in reach, so this is the newest run of any
origin. Never silent about it: the tab promises a colleague's
upload, and a run with no sender is not one. */}
{!fromColleague && (
<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>
No colleague upload in the last {scanned} ingestion run(s), so this is simply the
most recent run. It carries no sender, which means it was started from the Admin
UI (<strong>Batch Catalog Ingestion</strong>) rather than sent to{' '}
<code className="font-mono">POST /api/uploads/catalog</code>. Its eleven stages
are below all the same.
</span>
</div>
)}
{/* The filename IS the headline - `truncate` on a min-w-0 flex child
so a long name is cut rather than pushing the card sideways. */}
<h3 className="font-display text-base font-bold text-ink-950 flex items-center gap-2 min-w-0 mb-1.5">
<FileSpreadsheet className="h-4 w-4 shrink-0 text-amber-600" />
<span className="truncate" title={headlineName}>{headlineName}</span>
</h3>
{/* Everything that used to crowd the filename, demoted to one line. */}
<p className="mb-4 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[11px] text-slate-500">
<StatusIcon
className={`h-3.5 w-3.5 shrink-0 ${STATUS_TONE[run.status] || 'text-slate-400'} ${
run.status === 'running' ? 'animate-spin' : ''
}`}
/>
{run.submitted_by ? (
<span>from <span className="font-bold text-amber-600">{run.submitted_by}</span></span>
) : (
<span>started from the Admin UI</span>
)}
<span className="text-slate-300">&middot;</span>
<span>{ago(run.created_at)}</span>
<span className="text-slate-300">&middot;</span>
<span className="font-mono uppercase">{run.status}</span>
{extraFiles > 0 && (
<>
<span className="text-slate-300">&middot;</span>
<span>+{extraFiles} more file(s), listed below</span>
</>
)}
</p>
{/* `partial` exists so this does not read as success: some files
landed and some did not. */}
{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.
</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>
{/* The third of the three things: a refused row, named, with the sheet
line number the sender sees on screen (header counted as row 1).
This is the only place in the app that reports them. */}
<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>
);
}