219 lines
10 KiB
JavaScript
219 lines
10 KiB
JavaScript
import React, { useEffect, useState } from '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 { 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 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.
|
|
*
|
|
* Three early sub-tabs (train/test dataset upload, stock-remaining discount
|
|
* allocator, and a trigger for the brand ingestion pipeline) were retired from
|
|
* the UI ONLY. Their endpoints are all still registered and still work; brand
|
|
* ingestion in particular is reachable from
|
|
* `python cli/ingest_brand.py "<brand>"` and from POST /api/catalog/generate.
|
|
*
|
|
* "Store Catalog Ingestion" - a single-file uploader - went further: the tab,
|
|
* its panel, and the /api/admin/store-catalog/* routes behind it were all
|
|
* deleted, because Batch Catalog Ingestion does the same job for up to twenty
|
|
* files and POST /api/uploads/catalog now runs a colleague's upload
|
|
* automatically. Do not go looking for those endpoints; they are gone. The
|
|
* eleven-stage pipeline they drove is not - it lives in
|
|
* app/core/store_catalog_pipeline.py and every route below still runs it.
|
|
*/
|
|
const TABS = [
|
|
{ id: 'project', label: 'Project Overview Details', icon: Database },
|
|
{ id: 'batch', label: 'Batch Catalog Ingestion', icon: Layers },
|
|
// 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 },
|
|
];
|
|
|
|
// 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 {
|
|
setProjectDetails(await api.getProjectDetails());
|
|
} catch (e) {
|
|
console.warn('Could not fetch project details', e);
|
|
} finally {
|
|
setLoadingProject(false);
|
|
}
|
|
};
|
|
|
|
// This effect also drove a 3-second poll of ingestion job status; that went
|
|
// with the ingest panel, leaving a single fetch on mount.
|
|
useEffect(() => {
|
|
fetchProjectDetails();
|
|
}, []);
|
|
|
|
// 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
|
|
// error, and the next tick retries.
|
|
//
|
|
// Expect a steady zero on a deployment running UPLOAD_AUTORUN=true: uploads
|
|
// go straight into the pipeline and never wait for review, so there is
|
|
// nothing to be notified about. The badge renders only above zero, so that
|
|
// reads as "nothing to do" rather than as a broken counter, and the poll
|
|
// stays because the setting can be turned off without a redeploy.
|
|
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
|
|
title="Admin Intelligence Panel"
|
|
subtitle="Brand catalogs, vector tables & single or batch spreadsheet ingestion"
|
|
icon={Wrench}
|
|
/>
|
|
|
|
<div className="mx-auto w-full max-w-6xl px-6 py-8 space-y-6">
|
|
<div className="flex items-center gap-2 border-b border-ink-900/10 pb-3 overflow-x-auto no-scrollbar">
|
|
{TABS.map(({ id, label, icon: Icon }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => setActiveTab(id)}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap cursor-pointer ${
|
|
activeTab === id
|
|
? 'bg-amber-500 text-slate-950 shadow-sm'
|
|
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
|
}`}
|
|
>
|
|
<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 === 'batch' && <BatchCatalogPanel adoptBatch={adoptedBatch} />}
|
|
|
|
{activeTab === 'inbox' && (
|
|
<UploadResultsPanel
|
|
onBatchStarted={(batch) => {
|
|
setAdoptedBatch(batch);
|
|
setActiveTab('batch');
|
|
setPendingUploads((n) => Math.max(0, n - batch.files_total));
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Starts from the same inbox but hands the batch to Dagster, and stays
|
|
put to show the run rather than handing off to the Batch tab - the
|
|
stage-by-stage view is the reason to be on this tab at all. */}
|
|
{activeTab === 'orchestration' && <OrchestrationPanel />}
|
|
|
|
{activeTab === 'project' && (
|
|
<div className="space-y-6">
|
|
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
|
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
|
|
<Database className="h-5 w-5 text-amber-500" /> Existing Project Details & System Architecture
|
|
</h2>
|
|
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
|
|
High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models.
|
|
</p>
|
|
|
|
{loadingProject ? (
|
|
<div className="py-8 text-center text-xs text-slate-400">Loading project details...</div>
|
|
) : projectDetails ? (
|
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
|
<p className="text-[11px] font-semibold uppercase text-amber-700">Total Brands</p>
|
|
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_brands}</p>
|
|
<p className="text-[10px] text-slate-400 mt-1">Ingested brand tables in pgvector</p>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
|
<p className="text-[11px] font-semibold uppercase text-emerald-700">Total Products</p>
|
|
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_products}</p>
|
|
<p className="text-[10px] text-slate-400 mt-1">Unique products with vector embeddings</p>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
|
<p className="text-[11px] font-semibold uppercase text-blue-700">S3 Storage Status</p>
|
|
<p className="font-mono text-xl font-bold text-ink-950 mt-2 uppercase">{projectDetails.s3_image_status}</p>
|
|
<p className="text-[10px] text-slate-400 mt-1">Bucket: {projectDetails.storage_bucket}</p>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 p-4">
|
|
<p className="text-[11px] font-semibold uppercase text-purple-700">System Version</p>
|
|
<p className="font-mono text-xl font-bold text-ink-950 mt-2">{projectDetails.version}</p>
|
|
<p className="text-[10px] text-slate-400 mt-1">Multi-Store & RAG Engine</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{projectDetails?.brand_product_counts && (
|
|
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
|
<h3 className="font-display text-sm font-bold text-ink-950 mb-4">Ingested Brand Database Breakdown</h3>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
|
{Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => (
|
|
<div key={bName} className="flex items-center justify-between p-3 rounded-xl border border-ink-900/10 bg-white">
|
|
<span className="text-xs font-bold text-ink-950">{bName}</span>
|
|
<span className="font-mono text-xs font-bold bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full">{count} items</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|