Frontend Feature Changes
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Wrench, Database, Layers, Inbox, Workflow } from 'lucide-react';
|
||||
import { Wrench, Database, Layers, ClipboardCheck, Workflow } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { BatchCatalogPanel } from './BatchCatalogPanel';
|
||||
import { InboxPanel } from './InboxPanel';
|
||||
import { UploadResultsPanel } from './UploadResultsPanel';
|
||||
import { OrchestrationPanel } from './OrchestrationPanel';
|
||||
|
||||
/*
|
||||
* The admin panel carries the read-only project overview plus three ingestion
|
||||
* views: a multi-file batch upload, the review inbox, and the Dagster
|
||||
* orchestration screen.
|
||||
* views: a multi-file batch upload, the results of a colleague's upload, and
|
||||
* the Dagster orchestration screen.
|
||||
*
|
||||
* "Upload Results" was the Review Inbox until it stopped being useful as one:
|
||||
* UPLOAD_AUTORUN defaults to true, so a colleague's upload runs on arrival and
|
||||
* the inbox is permanently empty. The tab now leads with what the pipeline did
|
||||
* with the newest colleague upload, and the approval gate reveals itself only
|
||||
* when files are actually waiting - which is what the badge below counts.
|
||||
*
|
||||
* TWO KINDS OF REMOVAL HAVE HAPPENED HERE, AND THE DIFFERENCE MATTERS.
|
||||
*
|
||||
@@ -30,7 +36,10 @@ import { OrchestrationPanel } from './OrchestrationPanel';
|
||||
const TABS = [
|
||||
{ id: 'project', label: 'Project Overview Details', icon: Database },
|
||||
{ id: 'batch', label: 'Batch Catalog Ingestion', icon: Layers },
|
||||
{ id: 'inbox', label: 'Review Inbox', icon: Inbox },
|
||||
// The id stays 'inbox' - the badge, the render guard and the onBatchStarted
|
||||
// wiring all key off it, and the tab still owns the review inbox; it just
|
||||
// leads with the pipeline result now.
|
||||
{ id: 'inbox', label: 'Upload Results', icon: ClipboardCheck },
|
||||
{ id: 'orchestration', label: 'Dagster Orchestration', icon: Workflow },
|
||||
];
|
||||
|
||||
@@ -64,7 +73,8 @@ export function AdminPage() {
|
||||
fetchProjectDetails();
|
||||
}, []);
|
||||
|
||||
// The badge. Polled here rather than inside InboxPanel so the count is live
|
||||
// The badge. Polled here rather than inside UploadResultsPanel so the count
|
||||
// is live
|
||||
// whichever tab is open - the whole point is finding out that a colleague
|
||||
// sent something while you were looking at a different screen. A failed poll
|
||||
// is swallowed: a transient network blip must not replace the panel with an
|
||||
@@ -132,7 +142,7 @@ export function AdminPage() {
|
||||
{activeTab === 'batch' && <BatchCatalogPanel adoptBatch={adoptedBatch} />}
|
||||
|
||||
{activeTab === 'inbox' && (
|
||||
<InboxPanel
|
||||
<UploadResultsPanel
|
||||
onBatchStarted={(batch) => {
|
||||
setAdoptedBatch(batch);
|
||||
setActiveTab('batch');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Inbox, Loader2, CheckCircle2, XCircle, AlertTriangle, ArrowRight,
|
||||
PlayCircle, Ban, FileSpreadsheet, RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
/*
|
||||
* Files a colleague dropped, waiting for you to decide.
|
||||
*
|
||||
* This is the admin half of the two-actor flow. The colleague never sees this
|
||||
* page - they POST to /api/uploads/catalog with an API key and that is the end
|
||||
* of their involvement. Nothing they upload runs until somebody ticks it here.
|
||||
*
|
||||
* Selection is per FILE, not per drop, and files from different drops can be
|
||||
* started as one batch. That is the whole reason the inbox is its own thing
|
||||
* rather than a list of pre-made batches waiting on Resume: Resume runs a whole
|
||||
* batch, and "two files from Monday plus one from today" has no expression in
|
||||
* that model.
|
||||
*
|
||||
* Once started, this hands off to the Batch tab, which already knows how to
|
||||
* render a running batch. Nothing here duplicates that.
|
||||
*/
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
function ago(seconds) {
|
||||
const s = Math.max(0, Math.floor(Date.now() / 1000 - seconds));
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function InboxPanel({ onBatchStarted }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [selected, setSelected] = useState(() => new Set());
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [useLlm, setUseLlm] = useState(true);
|
||||
// Ticked by default - see the same note in BatchCatalogPanel. A file
|
||||
// released from the inbox is the main way catalog rows are created, and
|
||||
// without this they arrive with no images.
|
||||
const [fetchImages, setFetchImages] = useState(true);
|
||||
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await api.listInbox();
|
||||
setData(next);
|
||||
// Drop any selection that is no longer pending - another tab may have
|
||||
// started or dismissed it, and a checkbox pointing at a file that is
|
||||
// already gone would 409 on the next click with no explanation.
|
||||
const live = new Set(
|
||||
next.submissions.flatMap((s) => s.files.map((f) => f.file_id))
|
||||
);
|
||||
setSelected((current) => new Set([...current].filter((id) => live.has(id))));
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Could not read the inbox.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
pollRef.current = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(pollRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const toggle = (fileId) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(fileId)) next.delete(fileId);
|
||||
else next.add(fileId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDrop = (submission) => {
|
||||
const ids = submission.files.map((f) => f.file_id);
|
||||
const allOn = ids.every((id) => selected.has(id));
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
ids.forEach((id) => (allOn ? next.delete(id) : next.add(id)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!selected.size) return;
|
||||
setBusy('start');
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const batch = await api.startBatchFromInbox([...selected], {
|
||||
use_llm: useLlm,
|
||||
fetch_images: fetchImages,
|
||||
});
|
||||
setSelected(new Set());
|
||||
setNotice(
|
||||
`Started ${batch.files_total} file(s) as one batch. Follow it on the ` +
|
||||
`Batch Catalog Ingestion tab.`
|
||||
);
|
||||
await load();
|
||||
if (onBatchStarted) onBatchStarted(batch);
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Could not start those files.');
|
||||
await load();
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
if (!selected.size) return;
|
||||
setBusy('dismiss');
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const result = await api.dismissInboxFiles([...selected]);
|
||||
setSelected(new Set());
|
||||
setNotice(`Dismissed ${result.dismissed} file(s). They will not be processed.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Could not dismiss those files.');
|
||||
await load();
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const submissions = data?.submissions || [];
|
||||
const pending = data?.pending_count ?? 0;
|
||||
// Nothing ticked means "Start selected" cannot fire, which makes the two
|
||||
// stage toggles below inert. On a deployment running UPLOAD_AUTORUN=true that
|
||||
// is the permanent state - this inbox never fills - so they stay greyed, which
|
||||
// is exactly the point: they must not read as settings that govern uploads.
|
||||
const stagesIdle = !selected.size;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
|
||||
<Inbox className="h-5 w-5 text-amber-500" /> Review Inbox
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
|
||||
Where spreadsheets wait <strong>when <code className="font-mono">UPLOAD_AUTORUN</code>
|
||||
{' '}is off</strong>. Nothing here has run yet — tick the files you want and
|
||||
press <strong>Start selected</strong> to send them through the 11-stage pipeline as
|
||||
one batch. Files from different drops can go in the same batch. With autorun on,
|
||||
uploads start on arrival and this stays empty; watch them under{' '}
|
||||
<strong>Dagster Orchestration</strong> instead.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
aria-label="Refresh the inbox"
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-ink-950 transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStart}
|
||||
disabled={!selected.size || busy}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500 text-slate-950 text-xs font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
|
||||
>
|
||||
{busy === 'start'
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <PlayCircle className="h-4 w-4" />}
|
||||
Start selected{selected.size ? ` (${selected.size})` : ''}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={!selected.size || busy}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-ink-900/15 bg-white text-xs font-bold text-slate-600 hover:text-maroon-600 disabled:opacity-40 transition cursor-pointer"
|
||||
>
|
||||
{busy === 'dismiss'
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Ban className="h-4 w-4" />}
|
||||
Dismiss
|
||||
</button>
|
||||
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{pending} file(s) awaiting review
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* THESE TWO GOVERN THE MANUAL START ABOVE, AND NOTHING ELSE.
|
||||
An upload that ran itself never passed through here: POST
|
||||
/api/uploads/catalog reads UPLOAD_AUTORUN_FETCH_IMAGES and
|
||||
UPLOAD_AUTORUN_USE_LLM on the server and ignores whatever the
|
||||
request asked for. What a given run actually used is on its own row
|
||||
under Dagster Orchestration -> Recent runs. */}
|
||||
<fieldset
|
||||
disabled={stagesIdle}
|
||||
className={`mt-4 rounded-xl border border-ink-900/10 bg-white p-3 transition ${
|
||||
stagesIdle ? 'opacity-40' : ''
|
||||
}`}
|
||||
>
|
||||
<legend className="px-1 text-[10px] font-bold uppercase tracking-wide text-slate-400">
|
||||
Applies to Start selected
|
||||
{stagesIdle && (
|
||||
<span className="font-normal normal-case"> — tick a file below first</span>
|
||||
)}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
className={`flex items-start gap-2 text-[11px] text-slate-600 ${
|
||||
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fetchImages}
|
||||
disabled={stagesIdle}
|
||||
onChange={(e) => setFetchImages(e.target.checked)}
|
||||
className={`mt-0.5 accent-amber-500 ${
|
||||
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-bold text-ink-950">Search for product images</span>
|
||||
{' '}— much slower, and makes outbound requests for every row.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className={`flex items-start gap-2 text-[11px] text-slate-600 ${
|
||||
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useLlm}
|
||||
disabled={stagesIdle}
|
||||
onChange={(e) => setUseLlm(e.target.checked)}
|
||||
className={`mt-0.5 accent-amber-500 ${
|
||||
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-bold text-ink-950">Use the LLM for missing descriptions</span>
|
||||
{' '}— fills blank descriptions where an Ollama server is reachable. It is not
|
||||
in production, so rows there simply keep the description the sheet gave them.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{notice && (
|
||||
<div className="mt-4 rounded-lg bg-emerald-500/5 border border-emerald-500/20 p-3 text-xs text-emerald-700 flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" /> {notice}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600 flex items-start gap-2">
|
||||
<XCircle className="h-4 w-4 shrink-0 mt-0.5" /> {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- the drops ------------------------------------------------ */}
|
||||
{data && submissions.length === 0 && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
|
||||
<Inbox className="h-8 w-8 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-xs text-slate-500">
|
||||
Nothing waiting — files sent to{' '}
|
||||
<code className="font-mono">/api/uploads/catalog</code> start on their own and appear
|
||||
under <strong>Dagster Orchestration → Recent runs</strong>.
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 mt-2">
|
||||
Set <code className="font-mono">UPLOAD_AUTORUN=false</code> to hold them here for a
|
||||
decision instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submissions.map((submission) => {
|
||||
const ids = submission.files.map((f) => f.file_id);
|
||||
const allOn = ids.length > 0 && ids.every((id) => selected.has(id));
|
||||
return (
|
||||
<div
|
||||
key={submission.submission_id}
|
||||
className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-display text-sm font-bold text-ink-950">
|
||||
from <span className="text-amber-600">{submission.submitted_by}</span>
|
||||
<span className="ml-2 font-normal text-slate-400">
|
||||
{ago(submission.created_at)} · {submission.files.length} file(s)
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDrop(submission)}
|
||||
className="shrink-0 text-[11px] font-bold text-slate-500 hover:text-ink-950 transition cursor-pointer"
|
||||
>
|
||||
{allOn ? 'Clear all' : 'Select all'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
{submission.files.map((file) => (
|
||||
<li key={file.file_id}>
|
||||
<label className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-ink-900/10 bg-white cursor-pointer hover:border-amber-500/40 transition">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(file.file_id)}
|
||||
onChange={() => toggle(file.file_id)}
|
||||
className="accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<FileSpreadsheet className="h-4 w-4 text-amber-600 shrink-0" />
|
||||
<span className="text-[11px] font-bold text-ink-950 truncate flex-1">
|
||||
{file.filename}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-slate-400 shrink-0">
|
||||
{file.rows_total} rows
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-slate-300 shrink-0 w-16 text-right">
|
||||
{fmtBytes(file.size_bytes)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!data && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-amber-500 mx-auto" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pending > 0 && (
|
||||
<p className="text-[11px] text-slate-400 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||
Dismissing a file does not delete it from the server; it only takes it out of this
|
||||
list so the count can reach zero. Staged files are purged automatically after a week.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, ArrowRight, CheckCircle2, Clock, FileSpreadsheet,
|
||||
Loader2, MinusCircle, PlayCircle, RefreshCw, Workflow, XCircle,
|
||||
Loader2, PlayCircle, RefreshCw, Workflow, XCircle,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { FILE_ICON, FILE_TONE, STAGE_FLAGS, ago, fmtBytes } from './pipelineFormat';
|
||||
import { FileStages, Stat } from './pipelineShared';
|
||||
|
||||
/*
|
||||
* Admin -> Dagster Orchestration.
|
||||
@@ -40,55 +42,6 @@ const RUN_POLL_MS = 3000;
|
||||
|
||||
const TERMINAL = new Set(['done', 'failed', 'partial', 'cancelled']);
|
||||
|
||||
const FILE_TONE = {
|
||||
done: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
|
||||
failed: 'bg-maroon-100 text-maroon-600 border-maroon-500/30',
|
||||
running: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
|
||||
queued: 'bg-white text-slate-500 border-ink-900/10',
|
||||
cancelled: 'bg-ink-100 text-slate-500 border-ink-900/10',
|
||||
};
|
||||
|
||||
// The two optional network stages, rendered per run under Recent runs. A run
|
||||
// that started on its own took these from the SERVER settings
|
||||
// (UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM), never from the
|
||||
// checkboxes on this page, so reading them back off the manifest is the only
|
||||
// honest report of what a colleague's upload actually did.
|
||||
const STAGE_FLAGS = [
|
||||
{ key: 'fetch_images', label: 'img', title: 'Product image search' },
|
||||
{ key: 'use_llm', label: 'llm', title: 'LLM description fill' },
|
||||
];
|
||||
|
||||
const FILE_ICON = {
|
||||
done: CheckCircle2,
|
||||
failed: XCircle,
|
||||
running: Loader2,
|
||||
queued: Clock,
|
||||
cancelled: MinusCircle,
|
||||
};
|
||||
|
||||
function ago(seconds) {
|
||||
const s = Math.max(0, Math.floor(Date.now() / 1000 - seconds));
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function fmtSecs(from, to) {
|
||||
if (!from || !to) return '';
|
||||
const s = to - from;
|
||||
if (s < 1) return '<1s';
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
||||
}
|
||||
|
||||
export function OrchestrationPanel() {
|
||||
const [inbox, setInbox] = useState(null);
|
||||
const [recent, setRecent] = useState([]);
|
||||
@@ -585,95 +538,3 @@ export function OrchestrationPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
/* One file, and every stage of the pipeline as it happened to that file.
|
||||
*
|
||||
* All eleven are drawn from the moment the batch exists, greyed out until
|
||||
* reached, so the shape of the pipeline is visible before anything runs and
|
||||
* does not reflow as stages appear.
|
||||
*
|
||||
* A stage is looked up BY INDEX rather than by position in `file.stages`.
|
||||
* Stages 8-11 run once per brand in the sheet, so the backend folds repeat
|
||||
* visits into one record per index; matching on index is what keeps a
|
||||
* three-brand file from appearing to run backwards. */
|
||||
function FileStages({ file, stageNames }) {
|
||||
const Icon = FILE_ICON[file.status] || Clock;
|
||||
const byIndex = new Map((file.stages || []).map((s) => [s.index, s]));
|
||||
const names = stageNames.length ? stageNames : (file.stages || []).map((s) => s.name);
|
||||
|
||||
return (
|
||||
<li className={`rounded-xl border p-3 ${FILE_TONE[file.status] || FILE_TONE.queued}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Icon className={`h-3.5 w-3.5 shrink-0 ${file.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
<span className="text-[11px] font-bold truncate">{file.filename}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
{file.started_at && file.finished_at && (
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{fmtSecs(file.started_at, file.finished_at)}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[10px] uppercase">{file.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{file.detail && <p className="mt-1.5 text-[10px] leading-relaxed">{file.detail}</p>}
|
||||
|
||||
{names.length > 0 && (
|
||||
<ol className="mt-2.5 space-y-1">
|
||||
{names.map((name, i) => {
|
||||
const index = i + 1;
|
||||
const record = byIndex.get(index);
|
||||
const done = record && record.finished_at;
|
||||
const active = record && !record.finished_at;
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-1 text-[10px] ${
|
||||
done
|
||||
? 'bg-white/60 text-slate-600'
|
||||
: active
|
||||
? 'bg-amber-500/15 text-amber-700 font-bold'
|
||||
: 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono w-7 shrink-0 text-right opacity-60">{index}/11</span>
|
||||
{done && <CheckCircle2 className="h-3 w-3 shrink-0 text-leaf-600" />}
|
||||
{active && <Loader2 className="h-3 w-3 shrink-0 animate-spin" />}
|
||||
{!record && <Clock className="h-3 w-3 shrink-0 opacity-40" />}
|
||||
<span className="truncate flex-1">{name}</span>
|
||||
{record && record.rows_total > 0 && (
|
||||
<span className="font-mono shrink-0 opacity-70">
|
||||
{record.rows_done}/{record.rows_total}
|
||||
</span>
|
||||
)}
|
||||
{done && (
|
||||
<span className="font-mono shrink-0 w-12 text-right opacity-50">
|
||||
{fmtSecs(record.started_at, record.finished_at)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = {
|
||||
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700',
|
||||
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-700',
|
||||
slate: 'border-ink-900/10 bg-white text-slate-600',
|
||||
maroon: 'border-maroon-500/20 bg-maroon-100/40 text-maroon-600',
|
||||
amber: 'border-amber-500/20 bg-amber-500/5 text-amber-700',
|
||||
};
|
||||
|
||||
function Stat({ label, value, tone }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${TONES[tone] || TONES.slate}`}>
|
||||
<p className="text-[10px] font-semibold uppercase">{label}</p>
|
||||
<p className="font-mono text-2xl font-extrabold text-ink-950 mt-0.5">{value ?? 0}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
670
src/pages/UploadResultsPanel.jsx
Normal file
670
src/pages/UploadResultsPanel.jsx
Normal file
@@ -0,0 +1,670 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, Ban, CheckCircle2, ClipboardCheck, Clock, FileSpreadsheet,
|
||||
Loader2, PlayCircle, RefreshCw, XCircle, ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { STAGE_FLAGS, ago, fmtBytes } from './pipelineFormat';
|
||||
import { FileStages, Stat } from './pipelineShared';
|
||||
|
||||
/*
|
||||
* Admin -> Upload Results.
|
||||
*
|
||||
* What happened to the last file a colleague sent. No picking, no clicking:
|
||||
* the newest colleague upload is found and followed automatically, all eleven
|
||||
* stages, down to which rows the validation gate refused and why.
|
||||
*
|
||||
* THIS TAB USED TO BE THE REVIEW INBOX, and the inbox is still here - see the
|
||||
* gate section below. It is just no longer the point of the screen, because
|
||||
* with UPLOAD_AUTORUN at its default of true a colleague's upload starts on
|
||||
* arrival and never waits for review, so the inbox is permanently empty and the
|
||||
* tab showed nothing but an empty state pointing at another tab.
|
||||
*
|
||||
* HOW THIS DIFFERS FROM DAGSTER ORCHESTRATION, which also draws stage
|
||||
* timelines: that screen is for a run YOU launched - you tick files and watch
|
||||
* the batch you started, including your own admin uploads. This one answers the
|
||||
* opposite question, about a run nobody here started, and it is the only place
|
||||
* that shows the per-row rejection reasons a sender needs to fix their sheet.
|
||||
*
|
||||
* WHICH RUN IS "THE COLLEAGUE'S". `submitted_by` is the discriminator and it is
|
||||
* exact, not a heuristic: POST /api/uploads/catalog always sets it (falling back
|
||||
* to the `sender` form label and then to "anonymous"), and the admin's own
|
||||
* POST /api/admin/catalog-batch/ingest never passes it at all, so it is null
|
||||
* there. A batch released from the inbox carries the original senders, which is
|
||||
* correct - it is still their upload, just started by hand.
|
||||
*/
|
||||
|
||||
// Two questions at two cadences. "Has anything new arrived?" is cheap and slow;
|
||||
// "how far has the run I am watching got?" is the one that needs to feel live,
|
||||
// and it stops itself at a terminal status so a tab left open overnight goes
|
||||
// quiet. Both are cleared on unmount.
|
||||
const DISCOVER_POLL_MS = 10000;
|
||||
const RUN_POLL_MS = 3000;
|
||||
|
||||
// How far back to look for a colleague upload. The endpoint returns ALL runs
|
||||
// newest-first, admin batches included, so a window of 20 could be filled by
|
||||
// the admin's own uploads and hide the colleague's. Slim rows are not tiny -
|
||||
// they keep stages[], rejections and errors - so ask for 50 on the steady-state
|
||||
// tick and widen to the endpoint's ceiling only when nothing was found.
|
||||
const DISCOVER_LIMIT = 50;
|
||||
const DISCOVER_MAX_LIMIT = 100;
|
||||
|
||||
// When to stop following a run. Wider than the Orchestration tab's set
|
||||
// (OrchestrationPanel.jsx), which omits both `retired` and `interrupted`.
|
||||
//
|
||||
// `interrupted` is not terminal on the backend - a Resume can still move it -
|
||||
// but nothing will move it on its own, so a 3s poll would hammer a batch that
|
||||
// cannot change until an operator acts. Park on it; the 10s discovery tick
|
||||
// notices if BATCH_AUTO_RESUME re-queues it after a restart.
|
||||
const SETTLED = new Set([
|
||||
'done', 'failed', 'partial', 'cancelled', 'retired', 'interrupted',
|
||||
]);
|
||||
|
||||
const STATUS_ICON = {
|
||||
done: CheckCircle2,
|
||||
partial: AlertTriangle,
|
||||
failed: XCircle,
|
||||
running: Loader2,
|
||||
interrupted: AlertTriangle,
|
||||
};
|
||||
|
||||
const STATUS_TONE = {
|
||||
done: 'text-leaf-600',
|
||||
partial: 'text-amber-600',
|
||||
failed: 'text-maroon-600',
|
||||
running: 'text-amber-600',
|
||||
interrupted: 'text-amber-600',
|
||||
};
|
||||
|
||||
/* Is this row a colleague's upload, and does it have anything to show?
|
||||
*
|
||||
* `submitted_by` alone is not enough. GET /batches drops only PENDING rows
|
||||
* (batch_catalog.py), so a drop that was dismissed outright survives the filter
|
||||
* as a RETIRED husk - it still carries the sender's name, but every file was
|
||||
* discarded, so it has no result and no stage timeline. Without the second
|
||||
* clause, dismissing a drop makes that husk "the latest upload" and the panel
|
||||
* renders eleven grey rows that will never fill in.
|
||||
*
|
||||
* A drop that was RELEASED rather than dismissed is harmless either way: the
|
||||
* run it produced is staged before the drop is retired, so the run is newer and
|
||||
* wins the ordering regardless. */
|
||||
function isColleagueUpload(batch) {
|
||||
return (
|
||||
typeof batch?.submitted_by === 'string' &&
|
||||
batch.submitted_by.trim() !== '' &&
|
||||
batch.status !== 'retired'
|
||||
);
|
||||
}
|
||||
|
||||
function IssueList({ title, items, tone = 'slate' }) {
|
||||
if (!items.length) return null;
|
||||
const border =
|
||||
tone === 'maroon' ? 'border-maroon-500/25 bg-maroon-100/30' : 'border-ink-900/10 bg-white';
|
||||
return (
|
||||
<div className={`mt-4 rounded-xl border p-3 ${border}`}>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wide text-slate-500 mb-1.5">
|
||||
{title} <span className="font-mono opacity-60">({items.length})</span>
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{items.map((text, i) => (
|
||||
<li key={i} className="text-[11px] leading-relaxed text-slate-600">
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadResultsPanel({ onBatchStarted }) {
|
||||
// --- the results half ---------------------------------------------------
|
||||
const [run, setRun] = useState(null);
|
||||
const [discovered, setDiscovered] = useState(false); // first discovery done
|
||||
const runIdRef = useRef(null);
|
||||
// Whether the widened lookup has already been spent - see discover().
|
||||
const escalatedRef = useRef(false);
|
||||
|
||||
// --- the gate half (only rendered when something is actually waiting) ----
|
||||
const [inbox, setInbox] = useState(null);
|
||||
const [selected, setSelected] = useState(() => new Set());
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [useLlm, setUseLlm] = useState(true);
|
||||
const [fetchImages, setFetchImages] = useState(true);
|
||||
|
||||
/* Find the newest colleague upload, and read the inbox in the same tick.
|
||||
*
|
||||
* A failed poll is swallowed rather than replacing the panel with an error:
|
||||
* a transient blip must not wipe a run the admin is reading, and the next
|
||||
* tick retries. Same reasoning as the badge poll in AdminPage. */
|
||||
const discover = useCallback(async () => {
|
||||
try {
|
||||
let { batches } = await api.listCatalogBatches(DISCOVER_LIMIT);
|
||||
let latest = (batches || []).find(isColleagueUpload);
|
||||
// Nothing in the window. Before concluding there is no colleague upload,
|
||||
// widen once to the endpoint's ceiling - a burst of admin batches can push
|
||||
// a real one past 50. Guarded so it happens at most once per mount (and
|
||||
// once per manual Refresh), never on the steady-state path where a run is
|
||||
// already on screen.
|
||||
if (!latest && !escalatedRef.current) {
|
||||
escalatedRef.current = true;
|
||||
({ batches } = await api.listCatalogBatches(DISCOVER_MAX_LIMIT));
|
||||
latest = (batches || []).find(isColleagueUpload);
|
||||
}
|
||||
if (latest && latest.batch_id !== runIdRef.current) {
|
||||
runIdRef.current = latest.batch_id;
|
||||
// Show the slim row immediately - it already carries stages[] and the
|
||||
// counters, because slim strips only result.products - then let the
|
||||
// detail poll replace it with the full record.
|
||||
setRun(latest);
|
||||
} else if (!latest) {
|
||||
runIdRef.current = null;
|
||||
setRun(null);
|
||||
}
|
||||
} catch {
|
||||
/* next tick retries */
|
||||
}
|
||||
try {
|
||||
setInbox(await api.listInbox());
|
||||
} catch {
|
||||
/* next tick retries */
|
||||
}
|
||||
setDiscovered(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
discover();
|
||||
const timer = setInterval(discover, DISCOVER_POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [discover]);
|
||||
|
||||
// Follow the watched run until it settles. Re-created whenever the id or the
|
||||
// status changes, so it stops itself the moment the run settles rather than
|
||||
// polling a finished batch forever.
|
||||
const runId = run?.batch_id;
|
||||
const runStatus = run?.status;
|
||||
useEffect(() => {
|
||||
if (!runId || SETTLED.has(runStatus)) return undefined;
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const full = await api.getCatalogBatch(runId);
|
||||
if (alive && full?.batch_id === runIdRef.current) setRun(full);
|
||||
} catch {
|
||||
/* next tick retries */
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, RUN_POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [runId, runStatus]);
|
||||
|
||||
// A run that was already settled when we adopted it never enters the poll
|
||||
// above, so fetch its full record once. A run that settles while being
|
||||
// followed already had its last fetch from that poll.
|
||||
useEffect(() => {
|
||||
if (!runId || !SETTLED.has(runStatus)) return undefined;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const full = await api.getCatalogBatch(runId);
|
||||
if (alive && full?.batch_id === runIdRef.current) setRun(full);
|
||||
} catch {
|
||||
/* the slim row is already on screen; it is enough */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// Deliberately keyed on the id alone: re-running this on every status
|
||||
// change would re-fetch a settled batch for no reason.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runId]);
|
||||
|
||||
// --- gate actions (unchanged behaviour, just conditionally rendered) -----
|
||||
const toggle = (fileId) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(fileId)) next.delete(fileId);
|
||||
else next.add(fileId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDrop = (submission) => {
|
||||
const ids = submission.files.map((f) => f.file_id);
|
||||
const allOn = ids.every((id) => selected.has(id));
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
ids.forEach((id) => (allOn ? next.delete(id) : next.add(id)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!selected.size) return;
|
||||
setBusy('start');
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const batch = await api.startBatchFromInbox([...selected], {
|
||||
use_llm: useLlm,
|
||||
fetch_images: fetchImages,
|
||||
});
|
||||
setSelected(new Set());
|
||||
setNotice(
|
||||
`Started ${batch.files_total} file(s) as one batch. Follow it on the ` +
|
||||
`Batch Catalog Ingestion tab.`
|
||||
);
|
||||
await discover();
|
||||
if (onBatchStarted) onBatchStarted(batch);
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Could not start those files.');
|
||||
await discover();
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
if (!selected.size) return;
|
||||
setBusy('dismiss');
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const result = await api.dismissInboxFiles([...selected]);
|
||||
setSelected(new Set());
|
||||
setNotice(`Dismissed ${result.dismissed} file(s). They will not be processed.`);
|
||||
await discover();
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Could not dismiss those files.');
|
||||
await discover();
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
// Drop any selection that is no longer pending - another tab may have started
|
||||
// or dismissed it, and a checkbox pointing at a file that is already gone
|
||||
// would 409 on the next click with no explanation.
|
||||
useEffect(() => {
|
||||
if (!inbox) return;
|
||||
const live = new Set(inbox.submissions.flatMap((s) => s.files.map((f) => f.file_id)));
|
||||
setSelected((current) => new Set([...current].filter((id) => live.has(id))));
|
||||
}, [inbox]);
|
||||
|
||||
const pending = inbox?.pending_count ?? 0;
|
||||
const submissions = inbox?.submissions || [];
|
||||
const totals = run?.totals;
|
||||
const files = run?.files || [];
|
||||
|
||||
const rejections = files.flatMap((f) =>
|
||||
(f.result?.rejections || []).map((r) => {
|
||||
const where = r.row == null ? 'row unknown' : `row ${r.row}`;
|
||||
const size = r.size ? ` ${r.size}` : '';
|
||||
return `${where} · ${r.product_name || '(unnamed)'}${size} — ${r.reason}`;
|
||||
})
|
||||
);
|
||||
const rowErrors = files.flatMap((f) =>
|
||||
(f.result?.errors || []).map(
|
||||
(e) => `row ${e.row} · ${e.product_name || '(unnamed)'} — ${e.error}`
|
||||
)
|
||||
);
|
||||
const warnings = files.flatMap((f) => (f.result?.warnings || []).map((w) => w));
|
||||
const storageErrors = files
|
||||
.filter((f) => f.result?.storage_error)
|
||||
.map((f) => `${f.filename} — ${f.result.storage_error}`);
|
||||
|
||||
const StatusIcon = STATUS_ICON[run?.status] || Clock;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ---- the review gate, only when something is actually waiting ------
|
||||
With UPLOAD_AUTORUN on this never renders. Turn the setting off and
|
||||
it comes back on its own, which is the behaviour settings.py promises
|
||||
- "the review inbox comes back with no code change". */}
|
||||
{pending > 0 && (
|
||||
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-6 shadow-xs">
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" /> Waiting for review
|
||||
</h2>
|
||||
<p className="text-xs text-slate-600 leading-relaxed max-w-3xl">
|
||||
<code className="font-mono">UPLOAD_AUTORUN</code> is off, so these have been held
|
||||
rather than run. Tick what you want and press <strong>Start selected</strong> to send
|
||||
them through the 11-stage pipeline as one batch. Files from different drops can go in
|
||||
the same batch.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStart}
|
||||
disabled={!selected.size || busy}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500 text-slate-950 text-xs font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
|
||||
>
|
||||
{busy === 'start'
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <PlayCircle className="h-4 w-4" />}
|
||||
Start selected{selected.size ? ` (${selected.size})` : ''}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={!selected.size || busy}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-ink-900/15 bg-white text-xs font-bold text-slate-600 hover:text-maroon-600 disabled:opacity-40 transition cursor-pointer"
|
||||
>
|
||||
{busy === 'dismiss'
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Ban className="h-4 w-4" />}
|
||||
Dismiss
|
||||
</button>
|
||||
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
{pending} file(s) awaiting review
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* These govern the manual start above and nothing else. An upload
|
||||
that ran itself never passed through here - the server reads
|
||||
UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM and ignores
|
||||
whatever a request asked for. What a given run actually used is
|
||||
the img/llm pair on its own header below. */}
|
||||
<fieldset className="mt-4 rounded-xl border border-ink-900/10 bg-white p-3">
|
||||
<legend className="px-1 text-[10px] font-bold uppercase tracking-wide text-slate-400">
|
||||
Applies to Start selected
|
||||
</legend>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fetchImages}
|
||||
onChange={(e) => setFetchImages(e.target.checked)}
|
||||
className="mt-0.5 accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-bold text-ink-950">Search for product images</span>
|
||||
{' '}— 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>
|
||||
{' '}— 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)} · {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 — including which rows were refused and why.
|
||||
It updates on its own; there is nothing to pick. Your own uploads live under{' '}
|
||||
<strong>Batch Catalog Ingestion</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// An explicit Refresh buys back the widened lookup, so an admin
|
||||
// who knows a colleague just uploaded can force the deep search.
|
||||
escalatedRef.current = false;
|
||||
discover();
|
||||
}}
|
||||
aria-label="Check for a newer upload"
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-ink-950 transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- the run ------------------------------------------------------ */}
|
||||
{!discovered && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-amber-500 mx-auto" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gate open but nothing has run: "no upload yet" would be flatly false -
|
||||
there is one, it is sitting above waiting for a decision. */}
|
||||
{discovered && !run && pending > 0 && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
|
||||
<Clock className="h-8 w-8 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-xs text-slate-500">
|
||||
Nothing has run yet — the upload above is waiting for your approval. Tick the
|
||||
files and press <strong>Start selected</strong>, and its stage breakdown appears here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discovered && !run && pending === 0 && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
|
||||
<ClipboardCheck className="h-8 w-8 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-xs text-slate-500">
|
||||
No colleague upload yet. The moment one arrives at{' '}
|
||||
<code className="font-mono">POST /api/uploads/catalog</code>, its run appears here
|
||||
with the full stage breakdown.
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 mt-2">
|
||||
Files you upload yourself are not shown here — they are under{' '}
|
||||
<strong>Batch Catalog Ingestion</strong>. Uploads older than a week are cleared by
|
||||
retention.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<div className="flex items-start justify-between gap-3 mb-1">
|
||||
<h3 className="font-display text-sm font-bold text-ink-950 flex items-center gap-2">
|
||||
<StatusIcon
|
||||
className={`h-4 w-4 ${STATUS_TONE[run.status] || 'text-slate-400'} ${
|
||||
run.status === 'running' ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
from <span className="text-amber-600">{run.submitted_by}</span>
|
||||
<span className="font-normal text-slate-400">
|
||||
{ago(run.created_at)} · {run.files_total} file(s)
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{STAGE_FLAGS.map(({ key, label, title }) => (
|
||||
<span
|
||||
key={key}
|
||||
title={title}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold border ${
|
||||
run[key]
|
||||
? 'bg-amber-500/15 border-amber-500/30 text-amber-600'
|
||||
: 'bg-white border-ink-900/10 text-slate-300 line-through'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
<span className="font-mono text-[11px] uppercase text-slate-400">{run.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="font-mono text-[10px] text-slate-400 mb-4">{run.batch_id}</p>
|
||||
|
||||
{/* `partial` exists so this does not read as success. Some files
|
||||
landed and some did not, and the counters below are the total of
|
||||
only the ones that did. */}
|
||||
{run.status === 'partial' && (
|
||||
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{run.files_done} of {run.files_total} file(s) landed; {run.files_failed} did not.
|
||||
The counters below cover only what succeeded.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.status === 'queued' && (
|
||||
<div className="mb-4 rounded-lg bg-white border border-ink-900/10 p-3 text-[11px] text-slate-500 flex items-start gap-2">
|
||||
<Clock className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{run.runner === 'dagster'
|
||||
? 'Staged and waiting for the Dagster orchestrator to claim it. Dagster is not deployed in production, so this will wait indefinitely there.'
|
||||
: 'Staged and waiting its turn - the ingestion queue was busy when this arrived.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.status === 'interrupted' && (
|
||||
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
The backend restarted while this was running, so its unfinished files were put
|
||||
back in the queue. Resume it from <strong>Batch Catalog Ingestion</strong>.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.detail && (
|
||||
<p className="mb-4 text-[11px] text-slate-500 leading-relaxed">{run.detail}</p>
|
||||
)}
|
||||
|
||||
{/* A storage error still leaves rows built, so it hides among the
|
||||
counters - but it is what forces the file to failed, so it is
|
||||
called out on its own. */}
|
||||
{storageErrors.length > 0 && (
|
||||
<div className="mb-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-[11px] text-maroon-600">
|
||||
<p className="font-bold mb-1">Could not write to the catalogue</p>
|
||||
{storageErrors.map((text, i) => (
|
||||
<p key={i} className="leading-relaxed">{text}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<FileStages
|
||||
key={`${file.index}-${file.filename}`}
|
||||
file={file}
|
||||
stageNames={run.stage_names || []}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Gated on something having landed, so a still-queued run shows the
|
||||
stage list rather than a wall of zeros. */}
|
||||
{totals && (run.files_done > 0 || run.files_failed > 0) && (
|
||||
<div className="mt-5 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<Stat label="Rows read" value={totals.rows_total} tone="slate" />
|
||||
<Stat label="Products built" value={totals.products_built} tone="blue" />
|
||||
<Stat label="Inserted" value={totals.inserted} tone="emerald" />
|
||||
<Stat label="Backfilled" value={totals.backfilled} tone="amber" />
|
||||
<Stat label="Unchanged" value={totals.skipped_existing} tone="slate" />
|
||||
<Stat label="Rejected" value={totals.rejected} tone="maroon" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.brands?.length > 0 && (
|
||||
<p className="mt-4 text-[11px] text-slate-500">
|
||||
Brands touched:{' '}
|
||||
<span className="font-bold text-ink-950">{run.brands.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The reason this screen exists rather than sending people to the
|
||||
Dagster tab: a refused row, named, with the sheet line number the
|
||||
sender sees on screen (header counted as row 1). */}
|
||||
<IssueList
|
||||
title="Refused by the validation gate"
|
||||
items={rejections}
|
||||
tone="maroon"
|
||||
/>
|
||||
<IssueList title="Rows that could not be read" items={rowErrors} />
|
||||
<IssueList title="Warnings" items={warnings} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/pages/pipelineFormat.js
Normal file
77
src/pages/pipelineFormat.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { CheckCircle2, Clock, Loader2, MinusCircle, XCircle } from 'lucide-react';
|
||||
|
||||
/*
|
||||
* Constants and formatters shared by the two ingestion screens.
|
||||
*
|
||||
* Kept apart from pipelineShared.jsx, which holds the components: a module that
|
||||
* exports both breaks React Fast Refresh, which oxlint flags.
|
||||
*
|
||||
* These lived inside OrchestrationPanel until UploadResultsPanel needed the
|
||||
* same timeline. Copying them again was the alternative - the old InboxPanel
|
||||
* and OrchestrationPanel already carried duplicate `ago`/`fmtBytes`, and
|
||||
* BatchCatalogPanel still carries its own `Stat` - and a second copy of
|
||||
* FileStages in particular is the one worth avoiding, because its stage lookup
|
||||
* is subtle (see the comment on the component).
|
||||
*
|
||||
* Moved verbatim. Nothing here changed behaviour when it was lifted; if this
|
||||
* file and the Orchestration screen ever disagree, this file is wrong.
|
||||
*/
|
||||
|
||||
export const FILE_TONE = {
|
||||
done: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
|
||||
failed: 'bg-maroon-100 text-maroon-600 border-maroon-500/30',
|
||||
running: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
|
||||
queued: 'bg-white text-slate-500 border-ink-900/10',
|
||||
cancelled: 'bg-ink-100 text-slate-500 border-ink-900/10',
|
||||
};
|
||||
|
||||
export const FILE_ICON = {
|
||||
done: CheckCircle2,
|
||||
failed: XCircle,
|
||||
running: Loader2,
|
||||
queued: Clock,
|
||||
cancelled: MinusCircle,
|
||||
};
|
||||
|
||||
// The two optional network stages, read back off the manifest. A run that
|
||||
// started on its own took these from the SERVER settings
|
||||
// (UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM), never from the
|
||||
// checkboxes on any page, so the manifest is the only honest report of what a
|
||||
// colleague's upload actually did.
|
||||
export const STAGE_FLAGS = [
|
||||
{ key: 'fetch_images', label: 'img', title: 'Product image search' },
|
||||
{ key: 'use_llm', label: 'llm', title: 'LLM description fill' },
|
||||
];
|
||||
|
||||
export function ago(seconds) {
|
||||
const s = Math.max(0, Math.floor(Date.now() / 1000 - seconds));
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export function fmtBytes(n) {
|
||||
if (!n) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function fmtSecs(from, to) {
|
||||
if (!from || !to) return '';
|
||||
const s = to - from;
|
||||
if (s < 1) return '<1s';
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
||||
}
|
||||
|
||||
// Stat tile tones. Semantic, not decorative: emerald for rows that landed,
|
||||
// maroon for rows that did not.
|
||||
export const TONES = {
|
||||
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700',
|
||||
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-700',
|
||||
slate: 'border-ink-900/10 bg-white text-slate-600',
|
||||
maroon: 'border-maroon-500/20 bg-maroon-100/40 text-maroon-600',
|
||||
amber: 'border-amber-500/20 bg-amber-500/5 text-amber-700',
|
||||
};
|
||||
100
src/pages/pipelineShared.jsx
Normal file
100
src/pages/pipelineShared.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, Clock, Loader2 } from 'lucide-react';
|
||||
import { FILE_ICON, FILE_TONE, TONES, fmtSecs } from './pipelineFormat';
|
||||
|
||||
/*
|
||||
* The two pieces of a batch run both ingestion screens draw: the eleven-stage
|
||||
* timeline and the stat tile. Constants and formatters live in
|
||||
* pipelineFormat.js so this module exports components only.
|
||||
*
|
||||
* Moved verbatim out of OrchestrationPanel when UploadResultsPanel needed the
|
||||
* same timeline. Nothing changed behaviour in the move; if this file and the
|
||||
* Orchestration screen ever disagree, this file is wrong.
|
||||
*/
|
||||
|
||||
/* One file, and every stage of the pipeline as it happened to that file.
|
||||
*
|
||||
* Renders an <li>, so the caller must wrap it in a list.
|
||||
*
|
||||
* All eleven are drawn from the moment the batch exists, greyed out until
|
||||
* reached, so the shape of the pipeline is visible before anything runs and
|
||||
* does not reflow as stages appear.
|
||||
*
|
||||
* A stage is looked up BY INDEX rather than by position in `file.stages`.
|
||||
* Stages 8-11 run once per brand in the sheet, so the backend folds repeat
|
||||
* visits into one record per index; matching on index is what keeps a
|
||||
* three-brand file from appearing to run backwards. */
|
||||
export function FileStages({ file, stageNames }) {
|
||||
const Icon = FILE_ICON[file.status] || Clock;
|
||||
const byIndex = new Map((file.stages || []).map((s) => [s.index, s]));
|
||||
const names = stageNames.length ? stageNames : (file.stages || []).map((s) => s.name);
|
||||
|
||||
return (
|
||||
<li className={`rounded-xl border p-3 ${FILE_TONE[file.status] || FILE_TONE.queued}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Icon className={`h-3.5 w-3.5 shrink-0 ${file.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
<span className="text-[11px] font-bold truncate">{file.filename}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
{file.started_at && file.finished_at && (
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{fmtSecs(file.started_at, file.finished_at)}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[10px] uppercase">{file.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{file.detail && <p className="mt-1.5 text-[10px] leading-relaxed">{file.detail}</p>}
|
||||
|
||||
{names.length > 0 && (
|
||||
<ol className="mt-2.5 space-y-1">
|
||||
{names.map((name, i) => {
|
||||
const index = i + 1;
|
||||
const record = byIndex.get(index);
|
||||
const done = record && record.finished_at;
|
||||
const active = record && !record.finished_at;
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-1 text-[10px] ${
|
||||
done
|
||||
? 'bg-white/60 text-slate-600'
|
||||
: active
|
||||
? 'bg-amber-500/15 text-amber-700 font-bold'
|
||||
: 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono w-7 shrink-0 text-right opacity-60">{index}/11</span>
|
||||
{done && <CheckCircle2 className="h-3 w-3 shrink-0 text-leaf-600" />}
|
||||
{active && <Loader2 className="h-3 w-3 shrink-0 animate-spin" />}
|
||||
{!record && <Clock className="h-3 w-3 shrink-0 opacity-40" />}
|
||||
<span className="truncate flex-1">{name}</span>
|
||||
{record && record.rows_total > 0 && (
|
||||
<span className="font-mono shrink-0 opacity-70">
|
||||
{record.rows_done}/{record.rows_total}
|
||||
</span>
|
||||
)}
|
||||
{done && (
|
||||
<span className="font-mono shrink-0 w-12 text-right opacity-50">
|
||||
{fmtSecs(record.started_at, record.finished_at)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({ label, value, tone }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${TONES[tone] || TONES.slate}`}>
|
||||
<p className="text-[10px] font-semibold uppercase">{label}</p>
|
||||
<p className="font-mono text-2xl font-extrabold text-ink-950 mt-0.5">{value ?? 0}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user