diff --git a/apps/admin/src/app/experiments/page.tsx b/apps/admin/src/app/experiments/page.tsx deleted file mode 100644 index 6134991..0000000 --- a/apps/admin/src/app/experiments/page.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { AdminShell } from '@/components/AdminShell'; - -const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow'; -const airflowUrl = process.env.NEXT_PUBLIC_AIRFLOW_URL ?? '/airflow'; - -export default function ExperimentsPage() { - return ( - -
-

MLOps

-

- Experiment tracking, dataset management, and pipeline orchestration live in dedicated external services. - Each has its own auth — see{' '} - MLOps runbook - {' '}for credentials and first-time setup. -

- -
-

Experiment tracking

-
- - -
-
- -
-

Pipeline orchestration

-
- - -
-
- -
-

Bandit state ops

-

- Per-user LinUCB reset is available on the{' '} - Users page - {' '}→ user detail view. -

-
-
-
- ); -} - -function ExternalCard({ title, description, href, label }: { - title: string; - description: string; - href: string; - label: string; -}) { - return ( -
-
-

{title}

-

{description}

-
- - {label} - -
- ); -} diff --git a/apps/admin/src/app/models/page.tsx b/apps/admin/src/app/models/page.tsx deleted file mode 100644 index 012c768..0000000 --- a/apps/admin/src/app/models/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { AdminShell } from '@/components/AdminShell'; - -const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow'; - -export default function ModelsPage() { - return ( - -
-

Model registry

-

- Model lifecycle (runs, versions, promotions, artifacts) is managed in MLflow. - Auth is separate — log in with your MLflow credentials. -

- - -
-
- ); -} - -function ExternalCard({ title, description, href, label }: { - title: string; - description: string; - href: string; - label: string; -}) { - return ( -
-
-

{title}

-

{description}

-
- - {label} ↗ - -
- ); -} diff --git a/apps/admin/src/app/simulations/page.tsx b/apps/admin/src/app/simulations/page.tsx deleted file mode 100644 index 6a074fc..0000000 --- a/apps/admin/src/app/simulations/page.tsx +++ /dev/null @@ -1,499 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { AdminShell } from '@/components/AdminShell'; -import { - type PolicySummary, - type SimEvent, - type SimRun, - getSimRun, - getSimRuns, - startSimulation, -} from '@/lib/api'; - -const KNOWN_POLICIES = ['linucb-v1', 'egreedy-v1']; -const ACTIONS = ['done', 'snooze', 'dismiss']; -// Shown as reference only — actual reward is dwell-time inferred for 'done' -const ACTION_REWARDS: Record = { - done: 1.0, snooze: 0.1, dismiss: -1.0, -}; - -// ── SVG reward curve ──────────────────────────────────────────────────────── - -function RewardCurve({ summary, policies }: { summary: Record; policies: string[] }) { - const W = 520, H = 160, PAD = { t: 10, r: 10, b: 30, l: 40 }; - const iW = W - PAD.l - PAD.r; - const iH = H - PAD.t - PAD.b; - - const allVals = policies.flatMap((p) => summary[p]?.cumulative_rewards ?? []); - const minY = Math.min(0, ...allVals); - const maxY = Math.max(1, ...allVals); - const n = Math.max(...policies.map((p) => (summary[p]?.cumulative_rewards ?? []).length)); - - const xScale = (i: number) => PAD.l + (i / Math.max(1, n - 1)) * iW; - const yScale = (v: number) => PAD.t + iH - ((v - minY) / (maxY - minY)) * iH; - - const COLORS = ['#818cf8', '#34d399', '#f87171', '#fbbf24']; - - const path = (vals: number[]) => - vals - .map((v, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${yScale(v).toFixed(1)}`) - .join(' '); - - // Axis labels - const yLabels = [minY, (minY + maxY) / 2, maxY]; - - return ( - - {/* Grid */} - {yLabels.map((v, i) => ( - - - - {v.toFixed(1)} - - - ))} - {/* Zero line */} - {minY < 0 && ( - - )} - {/* Curves */} - {policies.map((p, pi) => { - const vals = summary[p]?.cumulative_rewards ?? []; - if (!vals.length) return null; - return ( - - - - - ); - })} - {/* X axis */} - - Round - {/* Legend */} - {policies.map((p, pi) => ( - - - {p} - - ))} - - ); -} - -// ── Action distribution table ─────────────────────────────────────────────── - -function ActionTable({ - summary, - policies, -}: { - summary: Record; - policies: string[]; -}) { - return ( - - - - - {policies.map((p) => ( - - ))} - - - - - {ACTIONS.map((action) => ( - - - {policies.map((p) => { - const n = summary[p]?.action_counts?.[action] ?? 0; - const total = Object.values(summary[p]?.action_counts ?? {}).reduce( - (a, b) => a + b, 0 - ); - const pct = total > 0 ? ((n / total) * 100).toFixed(1) : '—'; - return ( - - ); - })} - - - ))} - -
Action{p}Reward
{action} - {n} ({pct}%) - 0 ? 'text-green-400' : ACTION_REWARDS[action] < 0 ? 'text-red-400' : 'text-gray-500'}`}> - {ACTION_REWARDS[action] >= 0 ? '+' : ''}{ACTION_REWARDS[action]} -
- ); -} - -// ── Per-persona breakdown ─────────────────────────────────────────────────── - -function PersonaTable({ - breakdown, - policies, -}: { - breakdown: Record>; - policies: string[]; -}) { - const personas = Object.keys(breakdown); - return ( - - - - - {policies.map((p) => ( - - ))} - - - - - {personas.map((persona) => { - const pdata = breakdown[persona]; - const best = policies.reduce((a, b) => - (pdata[a]?.reward ?? -Infinity) >= (pdata[b]?.reward ?? -Infinity) ? a : b - ); - return ( - - - {policies.map((p) => { - const d = pdata[p]; - const mean = d && d.n > 0 ? (d.reward / d.n).toFixed(3) : '—'; - return ( - - ); - })} - - - ); - })} - -
Persona{p}
mean reward
Winner
{persona} - {mean} - {best}
- ); -} - -// ── Run detail panel ──────────────────────────────────────────────────────── - -function RunDetail({ runId, onClose }: { runId: string; onClose: () => void }) { - const [data, setData] = useState<{ run: SimRun; events: SimEvent[] } | null>(null); - const [error, setError] = useState(''); - const pollRef = useRef | null>(null); - - const load = async () => { - try { - const d = await getSimRun(runId); - setData(d); - if (d.run.status !== 'running' && d.run.status !== 'pending') { - if (pollRef.current) clearInterval(pollRef.current); - } - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Failed to load'); - if (pollRef.current) clearInterval(pollRef.current); - } - }; - - useEffect(() => { - load(); - pollRef.current = setInterval(load, 3000); - return () => { if (pollRef.current) clearInterval(pollRef.current); }; - }, [runId]); - - const run = data?.run; - const summary: Record | null = run?.summaryJson - ? JSON.parse(run.summaryJson) - : null; - const breakdown: Record> | null = - run?.personaBreakdownJson ? JSON.parse(run.personaBreakdownJson) : null; - const policies = run ? [run.policyA, run.policyB] : []; - - return ( -
-
-
-
-

Simulation {runId}

- {run && ( -

- {run.nUsers} users × {run.nRounds} rounds × {run.tasksPerRound} tasks - {' · '}{run.useLlm ? 'LLM judge' : 'Rule judge'} -

- )} -
- -
- - {error &&

{error}

} - - {run && ( -
- - {run.winner && run.status === 'done' && ( - - Winner: {run.winner} - - )} -
- )} - - {summary && ( - <> - {/* Metric cards */} -
- {policies.map((p) => ( -
-
{p}
-
- - - -
-
- ))} -
- - {/* Cumulative reward chart */} -
-

Cumulative reward over rounds

-
- -
-
- - {/* Action distribution */} -
-

Action distribution

- -
- - )} - - {breakdown && ( -
-

Per-persona mean reward

- -
- )} - - {run?.status === 'running' && ( -

Simulation running — auto-refreshing every 3s…

- )} -
-
- ); -} - -// ── Status badge ──────────────────────────────────────────────────────────── - -function StatusBadge({ status }: { status: string }) { - const styles: Record = { - pending: 'bg-gray-800 text-gray-400', - running: 'bg-yellow-900/60 text-yellow-300 border border-yellow-700', - done: 'bg-green-900/60 text-green-300 border border-green-700', - failed: 'bg-red-900/60 text-red-300 border border-red-700', - }; - return ( - - {status} - - ); -} - -function Metric({ label, value }: { label: string; value: string | undefined }) { - return ( -
-
{label}
-
{value ?? '—'}
-
- ); -} - -// ── Main page ─────────────────────────────────────────────────────────────── - -export default function SimulationsPage() { - const [runs, setRuns] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [selectedId, setSelectedId] = useState(null); - - // Form state - const [nUsers, setNUsers] = useState(5); - const [nRounds, setNRounds] = useState(20); - const [tasksPerRound, setTasksPerRound] = useState(8); - const [useLlm, setUseLlm] = useState(false); - const [policyA, setPolicyA] = useState('linucb-v1'); - const [policyB, setPolicyB] = useState('egreedy-v1'); - const [launching, setLaunching] = useState(false); - const [launchError, setLaunchError] = useState(''); - - const loadRuns = async () => { - setLoading(true); - try { - const { runs: r } = await getSimRuns(); - setRuns(r); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Failed to load'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { loadRuns(); }, []); - - const handleStart = async () => { - if (policyA === policyB) { - setLaunchError('Policies must be different'); - return; - } - setLaunching(true); - setLaunchError(''); - try { - const { id } = await startSimulation({ - nUsers, - nRounds, - tasksPerRound, - useLlm, - policies: [policyA, policyB], - }); - await loadRuns(); - setSelectedId(id); - } catch (e: unknown) { - setLaunchError(e instanceof Error ? e.message : 'Failed to start'); - } finally { - setLaunching(false); - } - }; - - return ( - -
-
-

Simulations

-

- Compare recommendation policies offline using synthetic users and LLM-judged reactions. - ml/serving must be running. -

-
- - {/* Launch form */} -
-

New simulation

-
- - - - - - - - setNUsers(Number(e.target.value))} className={inputCls} /> - - - setNRounds(Number(e.target.value))} className={inputCls} /> - - - setTasksPerRound(Number(e.target.value))} className={inputCls} /> - - - - {!useLlm &&

Deterministic rule judge

} - {useLlm &&

Requires ANTHROPIC_API_KEY

} -
-
- {launchError &&

{launchError}

} - -
- - {/* Runs list */} -
-
-

Past runs

- -
- - {loading &&

Loading…

} - {error &&

{error}

} - - {runs.length === 0 && !loading && ( -

No simulation runs yet.

- )} - -
- {runs.map((run) => ( - - ))} -
-
-
- - {selectedId && ( - setSelectedId(null)} /> - )} -
- ); -} - -// ── Small helpers ─────────────────────────────────────────────────────────── - -const inputCls = - 'w-full bg-gray-800 border border-gray-700 rounded px-2.5 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-indigo-500'; -const selectCls = - 'w-full bg-gray-800 border border-gray-700 rounded px-2.5 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-indigo-500'; - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {children} -
- ); -} diff --git a/apps/admin/src/components/AdminShell.tsx b/apps/admin/src/components/AdminShell.tsx index 34aa5a4..202b8ca 100644 --- a/apps/admin/src/components/AdminShell.tsx +++ b/apps/admin/src/components/AdminShell.tsx @@ -6,31 +6,54 @@ import { usePathname } from 'next/navigation'; const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow'; const airflowUrl = process.env.NEXT_PUBLIC_AIRFLOW_URL ?? '/airflow'; -type NavItem = - | { href: string; label: string; external?: false } - | { href: string; label: string; external: true }; +type NavItem = { + href: string; + label: string; + external?: boolean; +}; -const NAV: NavItem[] = [ - { href: '/', label: 'Overview' }, - { href: '/users', label: 'Users' }, - { href: '/events', label: 'Events' }, - { href: '/features', label: 'Features' }, - { href: '/tips', label: 'Rec log' }, - { href: '/reward-analytics', label: 'Rewards' }, - { href: '/experiments', label: 'MLOps' }, - { href: '/simulations', label: 'Simulations' }, - { href: '/models', label: 'Models' }, - { href: '/data-quality', label: 'Data quality' }, - { href: '/ops', label: 'Ops' }, - { href: '/sql', label: 'SQL runner' }, - { href: '/health', label: 'Health' }, - { href: '/audit', label: 'Audit log' }, - { href: '/docs', label: 'Docs' }, -]; +type NavSection = { + label?: string; + items: NavItem[]; +}; -const NAV_EXTERNAL: NavItem[] = [ - { href: mlflowUrl, label: 'MLflow ↗', external: true }, - { href: airflowUrl, label: 'Airflow ↗', external: true }, +const NAV: NavSection[] = [ + { + items: [{ href: '/', label: 'Overview' }], + }, + { + label: 'Signals', + items: [ + { href: '/users', label: 'Users' }, + { href: '/events', label: 'Events' }, + { href: '/features', label: 'Features' }, + { href: '/data-quality', label: 'Data quality' }, + ], + }, + { + label: 'Recommender status', + items: [ + { href: '/tips', label: 'Tips' }, + { href: '/reward-analytics', label: 'Rewards' }, + ], + }, + { + label: 'Operations', + items: [ + { href: '/health', label: 'Health' }, + { href: '/ops', label: 'Ops' }, + { href: '/sql', label: 'SQL runner' }, + { href: '/audit', label: 'Audit log' }, + ], + }, + { + label: 'Resources', + items: [ + { href: '/docs', label: 'Docs' }, + { href: mlflowUrl, label: 'MLflow ↗', external: true }, + { href: airflowUrl, label: 'Airflow ↗', external: true }, + ], + }, ]; export function AdminShell({ children }: { children: React.ReactNode }) { @@ -45,36 +68,46 @@ export function AdminShell({ children }: { children: React.ReactNode }) { Admin -