File uploads external source
This commit is contained in:
@@ -380,6 +380,23 @@ export const api = {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// --- Admin: the review inbox (a colleague dropped files; you decide) ---
|
||||
// The uploader half is POST /api/uploads/catalog, which the browser never
|
||||
// calls - a colleague hits it with an X-API-Key from a script.
|
||||
listInbox: () => request('/api/admin/catalog-batch/inbox'),
|
||||
|
||||
startBatchFromInbox: (fileIds, { use_llm = false, fetch_images = false } = {}) =>
|
||||
request('/api/admin/catalog-batch/from-inbox', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_ids: fileIds, use_llm, fetch_images }),
|
||||
}),
|
||||
|
||||
dismissInboxFiles: (fileIds) =>
|
||||
request('/api/admin/catalog-batch/inbox/dismiss', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_ids: fileIds }),
|
||||
}),
|
||||
|
||||
// --- User workspace: single + batch product entry ---
|
||||
// Guarded by `add_product` / `upload_batch_products` - see
|
||||
// app/api/routers/user_products.py.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Wrench, Database, UploadCloud, Layers } from 'lucide-react';
|
||||
import { Wrench, Database, UploadCloud, Layers, Inbox } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { StoreCatalogPanel } from './StoreCatalogPanel';
|
||||
import { BatchCatalogPanel } from './BatchCatalogPanel';
|
||||
import { InboxPanel } from './InboxPanel';
|
||||
|
||||
/*
|
||||
* The admin panel carries two views: the read-only project overview, and the
|
||||
@@ -20,12 +21,22 @@ const TABS = [
|
||||
{ id: 'project', label: 'Project Overview Details', icon: Database },
|
||||
{ id: 'ingest', label: 'Store Catalog Ingestion', icon: UploadCloud },
|
||||
{ id: 'batch', label: 'Batch Catalog Ingestion', icon: Layers },
|
||||
{ id: 'inbox', label: 'Review Inbox', icon: Inbox },
|
||||
];
|
||||
|
||||
// How often the pending count is refreshed. Slower than the 3s a running batch
|
||||
// polls at, because this is a "has anything arrived?" question, not progress on
|
||||
// something already in flight - and it runs on whichever tab is open, so it is
|
||||
// the one poll in the app that never stops while the page is up.
|
||||
const INBOX_POLL_MS = 15000;
|
||||
|
||||
export function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('project');
|
||||
const [projectDetails, setProjectDetails] = useState(null);
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
const [pendingUploads, setPendingUploads] = useState(0);
|
||||
// Handed to BatchCatalogPanel when a run is started from the inbox.
|
||||
const [adoptedBatch, setAdoptedBatch] = useState(null);
|
||||
|
||||
const fetchProjectDetails = async () => {
|
||||
try {
|
||||
@@ -43,6 +54,29 @@ export function AdminPage() {
|
||||
fetchProjectDetails();
|
||||
}, []);
|
||||
|
||||
// The badge. Polled here rather than inside InboxPanel 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
|
||||
// error, and the next tick retries.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const check = async () => {
|
||||
try {
|
||||
const { pending_count: count } = await api.listInbox();
|
||||
if (alive) setPendingUploads(count || 0);
|
||||
} catch {
|
||||
/* next tick retries */
|
||||
}
|
||||
};
|
||||
check();
|
||||
const timer = setInterval(check, INBOX_POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100 text-ink-950 font-sans">
|
||||
<NavigationHeader
|
||||
@@ -64,13 +98,34 @@ export function AdminPage() {
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" /> {label}
|
||||
{id === 'inbox' && pendingUploads > 0 && (
|
||||
<span
|
||||
className={`ml-1 min-w-5 px-1.5 py-0.5 rounded-full text-[10px] font-extrabold leading-none ${
|
||||
activeTab === id
|
||||
? 'bg-slate-950 text-amber-400'
|
||||
: 'bg-amber-500 text-slate-950'
|
||||
}`}
|
||||
>
|
||||
{pendingUploads}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'ingest' && <StoreCatalogPanel />}
|
||||
|
||||
{activeTab === 'batch' && <BatchCatalogPanel />}
|
||||
{activeTab === 'batch' && <BatchCatalogPanel adoptBatch={adoptedBatch} />}
|
||||
|
||||
{activeTab === 'inbox' && (
|
||||
<InboxPanel
|
||||
onBatchStarted={(batch) => {
|
||||
setAdoptedBatch(batch);
|
||||
setActiveTab('batch');
|
||||
setPendingUploads((n) => Math.max(0, n - batch.files_total));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'project' && (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -50,7 +50,7 @@ function fmtBytes(n) {
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function BatchCatalogPanel() {
|
||||
export function BatchCatalogPanel({ adoptBatch = null }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
@@ -63,6 +63,18 @@ export function BatchCatalogPanel() {
|
||||
const pollRef = useRef(null);
|
||||
const batchIdRef = useRef(null);
|
||||
|
||||
// A batch started from the Review Inbox 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(() => {
|
||||
if (!adoptBatch?.batch_id) return;
|
||||
setBatch(adoptBatch);
|
||||
setFiles([]);
|
||||
setPreview(null);
|
||||
setError('');
|
||||
if (!TERMINAL.has(adoptBatch.status)) batchIdRef.current = adoptBatch.batch_id;
|
||||
}, [adoptBatch]);
|
||||
|
||||
// One interval for the component's lifetime, reading the id from a ref
|
||||
// rather than closing over it. Same reasoning as StoreCatalogPanel: starting
|
||||
// a second batch must not leave an older interval polling the previous one.
|
||||
|
||||
315
src/pages/InboxPanel.jsx
Normal file
315
src/pages/InboxPanel.jsx
Normal file
@@ -0,0 +1,315 @@
|
||||
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(false);
|
||||
const [fetchImages, setFetchImages] = useState(false);
|
||||
|
||||
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;
|
||||
|
||||
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">
|
||||
Spreadsheets sent in by a colleague through the upload API. 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.
|
||||
</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>
|
||||
|
||||
{/* Same defaults and the same warning as the batch tab. */}
|
||||
<div className="mt-4 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>
|
||||
{' '}— no effect unless Ollama is reachable, which it is not in production.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{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>{' '}
|
||||
appear here for review.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user