From 8f8f37056629c4f954db2001999b1a78881c951b Mon Sep 17 00:00:00 2001 From: sriram Date: Fri, 28 Aug 2026 08:54:00 +0530 Subject: [PATCH] File uploads external source --- src/api/client.js | 17 ++ src/pages/AdminPage.jsx | 59 +++++- src/pages/BatchCatalogPanel.jsx | 14 +- src/pages/InboxPanel.jsx | 315 ++++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 src/pages/InboxPanel.jsx diff --git a/src/api/client.js b/src/api/client.js index 1899f0f..2139321 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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. diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index eeecff1..712763b 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -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 (
{label} + {id === 'inbox' && pendingUploads > 0 && ( + + {pendingUploads} + + )} ))}
{activeTab === 'ingest' && } - {activeTab === 'batch' && } + {activeTab === 'batch' && } + + {activeTab === 'inbox' && ( + { + setAdoptedBatch(batch); + setActiveTab('batch'); + setPendingUploads((n) => Math.max(0, n - batch.files_total)); + }} + /> + )} {activeTab === 'project' && (
diff --git a/src/pages/BatchCatalogPanel.jsx b/src/pages/BatchCatalogPanel.jsx index 2eaf668..a656a03 100644 --- a/src/pages/BatchCatalogPanel.jsx +++ b/src/pages/BatchCatalogPanel.jsx @@ -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. diff --git a/src/pages/InboxPanel.jsx b/src/pages/InboxPanel.jsx new file mode 100644 index 0000000..1a84863 --- /dev/null +++ b/src/pages/InboxPanel.jsx @@ -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 ( +
+
+
+
+

+ Review Inbox +

+

+ Spreadsheets sent in by a colleague through the upload API. 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. +

+
+ +
+ +
+ + + + + + {pending} file(s) awaiting review + +
+ + {/* Same defaults and the same warning as the batch tab. */} +
+ + +
+ + {notice && ( +
+ {notice} +
+ )} + {error && ( +
+ {error} +
+ )} +
+ + {/* ---- the drops ------------------------------------------------ */} + {data && submissions.length === 0 && ( +
+ +

+ Nothing waiting. Files sent to /api/uploads/catalog{' '} + appear here for review. +

+
+ )} + + {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. +

+ )} +
+ ); +}