Files
catalogue_frontend/src/pages/AdminPage.jsx
2026-09-02 12:27:10 +05:30

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>
);
}