From 2108e402b007649b61d514c7462810a0256f5e6b Mon Sep 17 00:00:00 2001 From: sriram Date: Wed, 2 Sep 2026 12:27:10 +0530 Subject: [PATCH] Frontend Feature Changes --- src/pages/AdminPage.jsx | 24 +- src/pages/BatchCatalogPanel.jsx | 3 +- src/pages/InboxPanel.jsx | 363 ----------------- src/pages/OrchestrationPanel.jsx | 145 +------ src/pages/UploadResultsPanel.jsx | 670 +++++++++++++++++++++++++++++++ src/pages/pipelineFormat.js | 77 ++++ src/pages/pipelineShared.jsx | 100 +++++ 7 files changed, 869 insertions(+), 513 deletions(-) delete mode 100644 src/pages/InboxPanel.jsx create mode 100644 src/pages/UploadResultsPanel.jsx create mode 100644 src/pages/pipelineFormat.js create mode 100644 src/pages/pipelineShared.jsx 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. */} -
- - Applies to Start selected - {stagesIdle && ( - — tick a file below first - )} - -
- - -
-
- - {notice && ( -
- {notice} -
- )} - {error && ( -
- {error} -
- )} -
- - {/* ---- the drops ------------------------------------------------ */} - {data && submissions.length === 0 && ( -
- -

- Nothing waiting — files sent to{' '} - /api/uploads/catalog start on their own and appear - under Dagster Orchestration → Recent runs. -

-

- Set UPLOAD_AUTORUN=false to hold them here for a - decision instead. -

-
- )} - - {submissions.map((submission) => { - const ids = submission.files.map((f) => f.file_id); - const allOn = ids.length > 0 && ids.every((id) => selected.has(id)); - return ( -
-
-

- from {submission.submitted_by} - - {ago(submission.created_at)} · {submission.files.length} file(s) - -

- -
- -
    - {submission.files.map((file) => ( -
  • - -
  • - ))} -
-
- ); - })} - - {!data && ( -
- -
- )} - - {pending > 0 && ( -

- - 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 ( -
  • -
    - - - {file.filename} - - - {file.started_at && file.finished_at && ( - - {fmtSecs(file.started_at, file.finished_at)} - - )} - {file.status} - -
    - - {file.detail &&

    {file.detail}

    } - - {names.length > 0 && ( -
      - {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 ( -
    1. - {index}/11 - {done && } - {active && } - {!record && } - {name} - {record && record.rows_total > 0 && ( - - {record.rows_done}/{record.rows_total} - - )} - {done && ( - - {fmtSecs(record.started_at, record.finished_at)} - - )} -
    2. - ); - })} -
    - )} -
  • - ); -} - -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 ( -
    -

    {label}

    -

    {value ?? 0}

    -
    - ); -} 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. */} +
    + + Applies to Start selected + +
    + + +
    +
    + + {notice && ( +
    + {notice} +
    + )} + {error && ( +
    + {error} +
    + )} + +
    + {submissions.map((submission) => { + const ids = submission.files.map((f) => f.file_id); + const allOn = ids.length > 0 && ids.every((id) => selected.has(id)); + return ( +
    +
    +

    + from {submission.submitted_by} + + {ago(submission.created_at)} · {submission.files.length} file(s) + +

    + +
    +
      + {submission.files.map((file) => ( +
    • + +
    • + ))} +
    +
    + ); + })} +
    +
    + )} + + {/* ---- 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. +

    +
    + )} + + {discovered && !run && pending === 0 && ( +
    + +

    + 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. +

    +
    + )} + + {run && ( +
    +
    +

    + + from {run.submitted_by} + + {ago(run.created_at)} · {run.files_total} file(s) + +

    +
    + {STAGE_FLAGS.map(({ key, label, title }) => ( + + {label} + + ))} + {run.status} +
    +
    + +

    {run.batch_id}

    + + {/* `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) && ( +
    + + + + + + +
    + )} + + {run.brands?.length > 0 && ( +

    + Brands touched:{' '} + {run.brands.join(', ')} +

    + )} + + {/* 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 ( +
  • +
    + + + {file.filename} + + + {file.started_at && file.finished_at && ( + + {fmtSecs(file.started_at, file.finished_at)} + + )} + {file.status} + +
    + + {file.detail &&

    {file.detail}

    } + + {names.length > 0 && ( +
      + {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 ( +
    1. + {index}/11 + {done && } + {active && } + {!record && } + {name} + {record && record.rows_total > 0 && ( + + {record.rows_done}/{record.rows_total} + + )} + {done && ( + + {fmtSecs(record.started_at, record.finished_at)} + + )} +
    2. + ); + })} +
    + )} +
  • + ); +} + +export function Stat({ label, value, tone }) { + return ( +
    +

    {label}

    +

    {value ?? 0}

    +
    + ); +}