From e62c726ea4278cebad2dc1fbb8fc21d4cd282efb Mon Sep 17 00:00:00 2001 From: alvis Date: Thu, 16 Apr 2026 03:56:48 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20M1=20admin=20console=20=E2=80=94=20all?= =?UTF-8?q?=2010=20remaining=20pages=20+=20signal/quality/ops=20infrastruc?= =?UTF-8?q?ture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin console (issues #63–72): - Event stream viewer: live-tail ring buffer (500 events) with subject/user filters - Feature store browser: per-user feature vector history from ml/serving - Model registry panel: MLflow embed at /admin/models - Experiment dashboard: LinUCB per-user stats (pulls, reward, θ) + bandit reset - Recommendation log: per-tip explainability (policy, score, features, latency) - Reward analytics: daily reaction breakdown + per-policy compare - Data quality widget: missing-feature rate, stale-token rate, daily completeness - Ops actions: replay-signal, policy enable/disable; user actions link to Users page - SQL runner: read-only SELECT runner with saved queries - Health rollup: fan-out to api/ml/sqlite/event-bus with auto-refresh Backend: - tip_scores table: logs features+policy+score+latency at every scoring call (#67) - saved_queries table: per-admin saved SQL (#71) - Event bus: 500-event ring buffer + tail() API (#63) - Admin routes: /events, /tips, /reward-analytics, /data-quality, /health, /policies, /replay-signal, /sql, /saved-queries endpoints - /api/ml/* admin-gated proxy to ml/serving (#64, #66) - Shadow-policy registry in recommender (#56) ML serving: - /reset/{user_id}: clear bandit state + feature history (#66) - /stats/{user_id}: pulls, cumulative reward, estimated mean, θ (#66) - /features/{user_id}: last 100 feature vectors logged at scoring time (#64) - Meta (pulls, rewards) persisted alongside A/b matrices Web: - Tip action sheet adds Helpful / Not helpful buttons (#62) - TipFeedback type extended with helpful/not_helpful actions - Rewards mapped: helpful=+0.5, not_helpful=−0.5 Co-Authored-By: Claude Sonnet 4.6 --- apps/admin/src/app/audit/page.tsx | 12 + apps/admin/src/app/data-quality/page.tsx | 89 +++ .../src/app/docs/[category]/[slug]/page.tsx | 73 +++ apps/admin/src/app/docs/page.tsx | 82 +++ apps/admin/src/app/events/page.tsx | 93 +++ apps/admin/src/app/experiments/page.tsx | 124 ++++ apps/admin/src/app/features/page.tsx | 98 +++ apps/admin/src/app/forbidden/page.tsx | 10 + apps/admin/src/app/globals.css | 123 ++++ apps/admin/src/app/health/page.tsx | 71 ++ apps/admin/src/app/layout.tsx | 15 + apps/admin/src/app/login/page.tsx | 16 + apps/admin/src/app/models/page.tsx | 30 + apps/admin/src/app/ops/page.tsx | 114 ++++ apps/admin/src/app/page.tsx | 12 + apps/admin/src/app/reward-analytics/page.tsx | 144 +++++ apps/admin/src/app/sql/page.tsx | 152 +++++ apps/admin/src/app/tips/page.tsx | 97 +++ apps/admin/src/app/users/[id]/page.tsx | 12 + apps/admin/src/app/users/page.tsx | 12 + apps/admin/src/components/AdminShell.tsx | 58 ++ apps/admin/src/components/AuditLog.tsx | 112 ++++ .../src/components/OverviewDashboard.tsx | 165 +++++ apps/admin/src/components/UserDetail.tsx | 159 +++++ apps/admin/src/components/UsersTable.tsx | 134 ++++ apps/admin/src/lib/api.ts | 222 +++++++ apps/admin/src/lib/docs.ts | 119 ++++ apps/admin/src/middleware.ts | 45 ++ apps/web/src/app/tip/page.tsx | 15 +- ml/serving/main.py | 114 +++- packages/shared-types/src/http/tip.ts | 2 +- services/api/src/db/index.ts | 65 ++ services/api/src/db/schema.ts | 35 + services/api/src/events/bus.ts | 44 +- services/api/src/index.ts | 25 + services/api/src/routes/admin.ts | 609 ++++++++++++++++++ services/api/src/routes/recommender.ts | 122 +++- 37 files changed, 3386 insertions(+), 38 deletions(-) create mode 100644 apps/admin/src/app/audit/page.tsx create mode 100644 apps/admin/src/app/data-quality/page.tsx create mode 100644 apps/admin/src/app/docs/[category]/[slug]/page.tsx create mode 100644 apps/admin/src/app/docs/page.tsx create mode 100644 apps/admin/src/app/events/page.tsx create mode 100644 apps/admin/src/app/experiments/page.tsx create mode 100644 apps/admin/src/app/features/page.tsx create mode 100644 apps/admin/src/app/forbidden/page.tsx create mode 100644 apps/admin/src/app/globals.css create mode 100644 apps/admin/src/app/health/page.tsx create mode 100644 apps/admin/src/app/layout.tsx create mode 100644 apps/admin/src/app/login/page.tsx create mode 100644 apps/admin/src/app/models/page.tsx create mode 100644 apps/admin/src/app/ops/page.tsx create mode 100644 apps/admin/src/app/page.tsx create mode 100644 apps/admin/src/app/reward-analytics/page.tsx create mode 100644 apps/admin/src/app/sql/page.tsx create mode 100644 apps/admin/src/app/tips/page.tsx create mode 100644 apps/admin/src/app/users/[id]/page.tsx create mode 100644 apps/admin/src/app/users/page.tsx create mode 100644 apps/admin/src/components/AdminShell.tsx create mode 100644 apps/admin/src/components/AuditLog.tsx create mode 100644 apps/admin/src/components/OverviewDashboard.tsx create mode 100644 apps/admin/src/components/UserDetail.tsx create mode 100644 apps/admin/src/components/UsersTable.tsx create mode 100644 apps/admin/src/lib/api.ts create mode 100644 apps/admin/src/lib/docs.ts create mode 100644 apps/admin/src/middleware.ts create mode 100644 services/api/src/routes/admin.ts diff --git a/apps/admin/src/app/audit/page.tsx b/apps/admin/src/app/audit/page.tsx new file mode 100644 index 0000000..de73ab4 --- /dev/null +++ b/apps/admin/src/app/audit/page.tsx @@ -0,0 +1,12 @@ +import { AdminShell } from '@/components/AdminShell'; +import { AuditLog } from '@/components/AuditLog'; + +export const dynamic = 'force-dynamic'; + +export default function AuditPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/app/data-quality/page.tsx b/apps/admin/src/app/data-quality/page.tsx new file mode 100644 index 0000000..5b3fb6f --- /dev/null +++ b/apps/admin/src/app/data-quality/page.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AdminShell } from '@/components/AdminShell'; +import { getDataQuality } from '@/lib/api'; + +function Pct({ value }: { value: number }) { + const pct = (value * 100).toFixed(1); + const color = value < 0.05 ? 'text-green-400' : value < 0.2 ? 'text-yellow-400' : 'text-red-400'; + return {pct}%; +} + +export default function DataQualityPage() { + const [data, setData] = useState> | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + getDataQuality() + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + return ( + +
+

Data quality

+ {error &&

{error}

} + {loading &&

Loading…

} + + {data && ( + <> +
+
+
Scoring calls (30d)
+
{data.scoringCallsLast30d}
+
+
+
Missing feature rate
+
+
+
+
Integration tokens
+
{data.totalTokens}
+
+
+
Stale token rate (>7d)
+
+
+
+ +
+

Daily feature completeness (14d)

+ + + + + + + + + + + + {data.dailyQuality.map((row) => { + const coverage = row.total > 0 ? row.withFeatures / row.total : 0; + return ( + + + + + + + + ); + })} + {data.dailyQuality.length === 0 && ( + + )} + +
DateScoring callsWith featuresCoverageAvg candidates
{row.date}{row.total}{row.withFeatures}{row.avgCandidates?.toFixed(1) ?? '—'}
No data yet
+
+ + )} +
+
+ ); +} diff --git a/apps/admin/src/app/docs/[category]/[slug]/page.tsx b/apps/admin/src/app/docs/[category]/[slug]/page.tsx new file mode 100644 index 0000000..b9f35da --- /dev/null +++ b/apps/admin/src/app/docs/[category]/[slug]/page.tsx @@ -0,0 +1,73 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { AdminShell } from '@/components/AdminShell'; +import { getDoc, type DocCategory } from '@/lib/docs'; + +export const dynamic = 'force-dynamic'; + +const CATEGORY_LABELS: Record = { + adr: 'ADR', + architecture: 'Architecture', +}; + +function isDocCategory(value: string): value is DocCategory { + return value === 'adr' || value === 'architecture'; +} + +export default async function DocDetailPage({ + params, +}: { + params: { category: string; slug: string }; +}) { + if (!isDocCategory(params.category)) notFound(); + + const doc = await getDoc(params.category, params.slug); + if (!doc) notFound(); + + const categoryLabel = CATEGORY_LABELS[params.category]; + + return ( + +
+ {/* Breadcrumb */} + + + {/* Meta bar */} + {(doc.status || doc.date) && ( +
+ {doc.status && ( + + {doc.status} + + )} + {doc.date && {doc.date}} +
+ )} + + {/* Markdown body */} +
+
+
+ ); +} diff --git a/apps/admin/src/app/docs/page.tsx b/apps/admin/src/app/docs/page.tsx new file mode 100644 index 0000000..130486a --- /dev/null +++ b/apps/admin/src/app/docs/page.tsx @@ -0,0 +1,82 @@ +import Link from 'next/link'; +import { AdminShell } from '@/components/AdminShell'; +import { listAllDocs, type DocMeta } from '@/lib/docs'; + +export const dynamic = 'force-dynamic'; + +function StatusBadge({ status }: { status?: string }) { + if (!status) return null; + const color = + status === 'Accepted' + ? 'bg-emerald-900 text-emerald-300' + : status === 'Proposed' + ? 'bg-yellow-900 text-yellow-300' + : status === 'Deprecated' + ? 'bg-red-900 text-red-400' + : 'bg-gray-800 text-gray-400'; + return ( + {status} + ); +} + +function DocList({ docs, emptyText }: { docs: DocMeta[]; emptyText: string }) { + if (docs.length === 0) { + return

{emptyText}

; + } + return ( +
    + {docs.map((doc) => ( +
  • + + {doc.title} + + {doc.date && ( + + {doc.date} + + )} + +
  • + ))} +
+ ); +} + +export default async function DocsPage() { + const { adr, architecture } = await listAllDocs(); + + return ( + +
+
+

Docs

+

+ Architecture Decision Records and design notes from{' '} + docs/ +

+
+ +
+

+ Architecture Decision Records +

+
+ +
+
+ +
+

+ Architecture notes +

+
+ +
+
+
+
+ ); +} diff --git a/apps/admin/src/app/events/page.tsx b/apps/admin/src/app/events/page.tsx new file mode 100644 index 0000000..82b940b --- /dev/null +++ b/apps/admin/src/app/events/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { AdminShell } from '@/components/AdminShell'; +import { getEvents, StoredEvent } from '@/lib/api'; + +const SUBJECTS = ['', 'signals.tip', 'signals.task', 'signals.tip.served', 'signals.tip.feedback', 'signals.task.synced']; + +export default function EventsPage() { + const [events, setEvents] = useState([]); + const [subject, setSubject] = useState(''); + const [userId, setUserId] = useState(''); + const [live, setLive] = useState(true); + const [error, setError] = useState(''); + const sinceRef = useRef(0); + const timerRef = useRef | null>(null); + + const fetchEvents = async (reset = false) => { + try { + const since = reset ? 0 : sinceRef.current; + const res = await getEvents({ subject: subject || undefined, userId: userId || undefined, limit: 100, since }); + sinceRef.current = res.nextSince; + setEvents((prev) => { + const next = reset ? res.events : [...prev, ...res.events]; + return next.slice(-500); // keep last 500 + }); + setError(''); + } catch (e: any) { + setError(e.message); + } + }; + + useEffect(() => { + sinceRef.current = 0; + fetchEvents(true); + }, [subject, userId]); + + useEffect(() => { + if (live) { + timerRef.current = setInterval(() => fetchEvents(false), 2000); + } else if (timerRef.current) { + clearInterval(timerRef.current); + } + return () => { if (timerRef.current) clearInterval(timerRef.current); }; + }, [live, subject, userId]); + + return ( + +
+
+

Event stream

+
+ + +
+
+ +
+ + setUserId(e.target.value)} + placeholder="Filter by user ID" + className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300 w-64" + /> +
+ + {error &&

{error}

} + +
+ {events.length === 0 && ( +

No events yet. Waiting…

+ )} + {[...events].reverse().map((e) => ( +
+ {e.id} + {e.ts.slice(11, 19)} + {e.subject} + {JSON.stringify(e.payload)} +
+ ))} +
+
+
+ ); +} diff --git a/apps/admin/src/app/experiments/page.tsx b/apps/admin/src/app/experiments/page.tsx new file mode 100644 index 0000000..bb8bb26 --- /dev/null +++ b/apps/admin/src/app/experiments/page.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AdminShell } from '@/components/AdminShell'; +import { resetBandit } from '@/lib/api'; + +interface BanditStats { + user_id: string; + pulls: number; + reward_count: number; + cumulative_reward: number; + estimated_mean_reward: number; + theta: number[]; + last_updated: string | null; +} + +const FEATURE_LABELS = ['hour_sin', 'hour_cos', 'is_overdue', 'task_age', 'priority']; + +export default function ExperimentsPage() { + const [userId, setUserId] = useState(''); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [resetting, setResetting] = useState(false); + const [error, setError] = useState(''); + const [resetMsg, setResetMsg] = useState(''); + + const fetchStats = async () => { + if (!userId.trim()) return; + setLoading(true); + setError(''); + try { + const res = await fetch(`/api/ml/stats/${encodeURIComponent(userId.trim())}`, { credentials: 'include' }); + if (!res.ok) throw new Error(res.statusText); + setStats(await res.json()); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + const handleReset = async () => { + if (!userId.trim()) return; + if (!confirm(`Reset LinUCB state for user ${userId}?`)) return; + setResetting(true); + try { + await resetBandit(userId.trim()); + setResetMsg('Bandit state reset.'); + setStats(null); + } catch (e: any) { + setError(e.message); + } finally { + setResetting(false); + } + }; + + return ( + +
+

Experiment dashboard

+

LinUCB per-user bandit stats pulled from ml/serving.

+ +
+ setUserId(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && fetchStats()} + placeholder="User ID" + className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-80" + /> + + {stats && ( + + )} +
+ + {error &&

{error}

} + {resetMsg &&

{resetMsg}

} + {loading &&

Loading…

} + + {stats && ( +
+ + + + +
+ )} + + {stats?.theta && ( +
+

θ (learned weight vector)

+
+ {stats.theta.map((v, i) => ( +
+
{FEATURE_LABELS[i] ?? `feat_${i}`}
+
0 ? 'text-green-400' : v < 0 ? 'text-red-400' : 'text-gray-400'}`}> + {v.toFixed(4)} +
+
+ ))} +
+ {stats.last_updated && ( +

Last updated: {stats.last_updated}

+ )} +
+ )} +
+
+ ); +} + +function StatCard({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/admin/src/app/features/page.tsx b/apps/admin/src/app/features/page.tsx new file mode 100644 index 0000000..a32d43d --- /dev/null +++ b/apps/admin/src/app/features/page.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AdminShell } from '@/components/AdminShell'; + +interface FeatureEntry { + ts: string; + features: Record; + score: number; + tip_id: string; +} + +const FEATURE_NAMES = ['hour_of_day', 'is_overdue', 'task_age_days', 'priority']; + +export default function FeaturesPage() { + const [userId, setUserId] = useState(''); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const fetch_ = async () => { + if (!userId.trim()) return; + setLoading(true); + setError(''); + try { + const res = await fetch(`/api/ml/features/${encodeURIComponent(userId.trim())}`, { credentials: 'include' }); + if (!res.ok) throw new Error(res.statusText); + const data = await res.json(); + setHistory(data.history ?? []); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + return ( + +
+

Feature store browser

+

+ Features sent to ml/serving per scoring call for a user. Shows last 100 entries. +

+ +
+ setUserId(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && fetch_()} + placeholder="User ID" + className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-80" + /> + +
+ + {error &&

{error}

} + {loading &&

Loading…

} + + {history.length > 0 && ( +
+ + + + + + {FEATURE_NAMES.map((f) => ( + + ))} + + + + + {[...history].reverse().map((entry, i) => ( + + + + {FEATURE_NAMES.map((f) => ( + + ))} + + + ))} + +
TimeScore{f}Tip ID
{entry.ts.slice(11, 19)}{entry.score.toFixed(4)} + {String(entry.features[f] ?? '—')} + {entry.tip_id}
+
+ )} + + {history.length === 0 && !loading && userId && ( +

No scoring history for this user yet.

+ )} +
+
+ ); +} diff --git a/apps/admin/src/app/forbidden/page.tsx b/apps/admin/src/app/forbidden/page.tsx new file mode 100644 index 0000000..dcdf870 --- /dev/null +++ b/apps/admin/src/app/forbidden/page.tsx @@ -0,0 +1,10 @@ +export default function ForbiddenPage() { + return ( +
+
+

403 — Forbidden

+

Your account does not have admin access.

+
+
+ ); +} diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css new file mode 100644 index 0000000..7a9eadf --- /dev/null +++ b/apps/admin/src/app/globals.css @@ -0,0 +1,123 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #0a0a0a; + --foreground: #ededed; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: system-ui, -apple-system, sans-serif; +} + +/* ------------------------------------------------------------------------- + Markdown prose — used on /docs pages via the .prose-doc class. + Purposely minimal: dark theme, respects the existing gray palette. + ------------------------------------------------------------------------- */ +.prose-doc { + color: #d1d5db; /* gray-300 */ + line-height: 1.7; + font-size: 0.9375rem; +} + +.prose-doc h1, +.prose-doc h2, +.prose-doc h3, +.prose-doc h4 { + color: #f3f4f6; /* gray-100 */ + font-weight: 600; + margin-top: 1.75em; + margin-bottom: 0.5em; + line-height: 1.3; +} +.prose-doc h1 { font-size: 1.5rem; margin-top: 0; border-bottom: 1px solid #374151; padding-bottom: 0.4em; } +.prose-doc h2 { font-size: 1.2rem; } +.prose-doc h3 { font-size: 1.05rem; } +.prose-doc h4 { font-size: 0.95rem; } + +.prose-doc p { margin-top: 0.75em; margin-bottom: 0.75em; } +.prose-doc p:first-child { margin-top: 0; } + +.prose-doc a { + color: #818cf8; /* indigo-400 */ + text-decoration: underline; + text-underline-offset: 2px; +} +.prose-doc a:hover { color: #a5b4fc; } + +.prose-doc code { + background: #1f2937; /* gray-800 */ + border-radius: 3px; + padding: 0.15em 0.4em; + font-size: 0.85em; + color: #e5e7eb; + font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace; +} + +.prose-doc pre { + background: #111827; /* gray-900 */ + border: 1px solid #374151; + border-radius: 6px; + padding: 1em 1.25em; + overflow-x: auto; + margin: 1.25em 0; +} +.prose-doc pre code { + background: none; + padding: 0; + font-size: 0.85em; + color: #d1d5db; +} + +.prose-doc ul, +.prose-doc ol { + padding-left: 1.5em; + margin: 0.75em 0; +} +.prose-doc li { margin: 0.25em 0; } +.prose-doc ul { list-style-type: disc; } +.prose-doc ol { list-style-type: decimal; } + +.prose-doc blockquote { + border-left: 3px solid #4b5563; + margin: 1em 0; + padding: 0.25em 1em; + color: #9ca3af; + font-style: italic; +} + +.prose-doc table { + width: 100%; + border-collapse: collapse; + margin: 1.25em 0; + font-size: 0.875rem; +} +.prose-doc th { + text-align: left; + padding: 0.5em 0.75em; + background: #1f2937; + color: #9ca3af; + font-weight: 500; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid #374151; +} +.prose-doc td { + padding: 0.5em 0.75em; + border-bottom: 1px solid #1f2937; + vertical-align: top; +} +.prose-doc tr:last-child td { border-bottom: none; } + +.prose-doc hr { + border: none; + border-top: 1px solid #374151; + margin: 2em 0; +} + +.prose-doc strong { color: #f3f4f6; font-weight: 600; } +.prose-doc em { color: #d1d5db; } diff --git a/apps/admin/src/app/health/page.tsx b/apps/admin/src/app/health/page.tsx new file mode 100644 index 0000000..f345299 --- /dev/null +++ b/apps/admin/src/app/health/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AdminShell } from '@/components/AdminShell'; +import { getHealth, HealthStatus } from '@/lib/api'; + +const STATUS_STYLES: Record = { + ok: 'bg-green-900 text-green-300 border-green-800', + degraded: 'bg-yellow-900 text-yellow-300 border-yellow-800', + down: 'bg-red-900 text-red-300 border-red-800', +}; + +export default function HealthPage() { + const [health, setHealth] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const refresh = () => { + setLoading(true); + getHealth() + .then(setHealth) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + refresh(); + const t = setInterval(refresh, 15_000); + return () => clearInterval(t); + }, []); + + return ( + +
+
+

Health

+
+ {health && ( + + {health.ok ? 'All systems operational' : 'Degraded'} + + )} + +
+
+ + {error &&

{error}

} + {loading && !health &&

Checking…

} + + {health && ( + <> +
+ {health.services.map((svc) => ( +
+
{svc.name}
+
{svc.status}
+ {svc.latencyMs > 0 && ( +
{svc.latencyMs}ms
+ )} +
+ ))} +
+

Last checked: {health.checkedAt} · auto-refreshes every 15s

+ + )} +
+
+ ); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..e397ff2 --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'oO Admin', + description: 'oO admin console', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx new file mode 100644 index 0000000..6c4afaa --- /dev/null +++ b/apps/admin/src/app/login/page.tsx @@ -0,0 +1,16 @@ +export default function LoginPage() { + return ( +
+
+

oO Admin

+

Sign in via the main app first, then return here.

+ + Sign in with Google + +
+
+ ); +} diff --git a/apps/admin/src/app/models/page.tsx b/apps/admin/src/app/models/page.tsx new file mode 100644 index 0000000..8301fdc --- /dev/null +++ b/apps/admin/src/app/models/page.tsx @@ -0,0 +1,30 @@ +import { AdminShell } from '@/components/AdminShell'; + +export default function ModelsPage() { + const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? 'http://localhost:5000'; + + return ( + +
+
+

Model registry

+ + Open MLflow ↗ + +
+

+ MLflow is embedded below when running under the full compose profile. + Promote or archive model versions via the MLflow UI; each action writes to the audit log automatically. +

+
+