feat: M1 admin console — all 10 remaining pages + signal/quality/ops infrastructure

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 03:56:48 +00:00
parent 2402a140e9
commit e62c726ea4
37 changed files with 3386 additions and 38 deletions

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { AuditLog } from '@/components/AuditLog';
export const dynamic = 'force-dynamic';
export default function AuditPage() {
return (
<AdminShell>
<AuditLog />
</AdminShell>
);
}

View File

@@ -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 <span className={color}>{pct}%</span>;
}
export default function DataQualityPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof getDataQuality>> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
getDataQuality()
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
return (
<AdminShell>
<div className="space-y-6">
<h1 className="text-xl font-semibold">Data quality</h1>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{data && (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Scoring calls (30d)</div>
<div className="text-2xl font-semibold">{data.scoringCallsLast30d}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Missing feature rate</div>
<div className="text-2xl font-semibold"><Pct value={data.missingFeatureRate} /></div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Integration tokens</div>
<div className="text-2xl font-semibold">{data.totalTokens}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">Stale token rate (&gt;7d)</div>
<div className="text-2xl font-semibold"><Pct value={data.staleTokenRate} /></div>
</div>
</div>
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Daily feature completeness (14d)</h2>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-4">Date</th>
<th className="py-2 pr-4">Scoring calls</th>
<th className="py-2 pr-4">With features</th>
<th className="py-2 pr-4">Coverage</th>
<th className="py-2">Avg candidates</th>
</tr>
</thead>
<tbody>
{data.dailyQuality.map((row) => {
const coverage = row.total > 0 ? row.withFeatures / row.total : 0;
return (
<tr key={row.date} className="border-b border-gray-800/50">
<td className="py-1.5 pr-4 font-mono text-gray-500">{row.date}</td>
<td className="py-1.5 pr-4 text-gray-300">{row.total}</td>
<td className="py-1.5 pr-4 text-gray-300">{row.withFeatures}</td>
<td className="py-1.5 pr-4"><Pct value={coverage} /></td>
<td className="py-1.5 text-gray-300">{row.avgCandidates?.toFixed(1) ?? '—'}</td>
</tr>
);
})}
{data.dailyQuality.length === 0 && (
<tr><td colSpan={5} className="py-4 text-center text-gray-600">No data yet</td></tr>
)}
</tbody>
</table>
</div>
</>
)}
</div>
</AdminShell>
);
}

View File

@@ -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<DocCategory, string> = {
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 (
<AdminShell>
<div className="max-w-3xl space-y-6">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-gray-500">
<Link href="/docs" className="hover:text-gray-300 transition-colors">
Docs
</Link>
<span>/</span>
<span className="text-gray-400">{categoryLabel}</span>
<span>/</span>
<span className="text-gray-300 truncate">{doc.slug}</span>
</nav>
{/* Meta bar */}
{(doc.status || doc.date) && (
<div className="flex items-center gap-3 text-xs text-gray-500">
{doc.status && (
<span
className={`px-1.5 py-0.5 rounded font-medium ${
doc.status === 'Accepted'
? 'bg-emerald-900 text-emerald-300'
: doc.status === 'Proposed'
? 'bg-yellow-900 text-yellow-300'
: doc.status === 'Deprecated'
? 'bg-red-900 text-red-400'
: 'bg-gray-800 text-gray-400'
}`}
>
{doc.status}
</span>
)}
{doc.date && <span>{doc.date}</span>}
</div>
)}
{/* Markdown body */}
<article
className="prose-doc"
dangerouslySetInnerHTML={{ __html: doc.html }}
/>
</div>
</AdminShell>
);
}

View File

@@ -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 (
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${color}`}>{status}</span>
);
}
function DocList({ docs, emptyText }: { docs: DocMeta[]; emptyText: string }) {
if (docs.length === 0) {
return <p className="text-sm text-gray-500">{emptyText}</p>;
}
return (
<ul className="divide-y divide-gray-800">
{docs.map((doc) => (
<li key={doc.href}>
<Link
href={doc.href}
className="flex items-center gap-4 px-4 py-3 hover:bg-gray-800 transition-colors rounded"
>
<span className="flex-1 text-sm text-gray-200 leading-snug">{doc.title}</span>
<StatusBadge status={doc.status} />
{doc.date && (
<span className="text-xs text-gray-600 tabular-nums w-24 text-right">
{doc.date}
</span>
)}
</Link>
</li>
))}
</ul>
);
}
export default async function DocsPage() {
const { adr, architecture } = await listAllDocs();
return (
<AdminShell>
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="text-xl font-semibold">Docs</h1>
<p className="text-sm text-gray-500 mt-0.5">
Architecture Decision Records and design notes from{' '}
<code className="text-xs bg-gray-800 px-1 py-0.5 rounded">docs/</code>
</p>
</div>
<section className="space-y-2">
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium px-1">
Architecture Decision Records
</h2>
<div className="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden">
<DocList docs={adr} emptyText="No ADRs found." />
</div>
</section>
<section className="space-y-2">
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium px-1">
Architecture notes
</h2>
<div className="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden">
<DocList docs={architecture} emptyText="No architecture docs found." />
</div>
</section>
</div>
</AdminShell>
);
}

View File

@@ -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<StoredEvent[]>([]);
const [subject, setSubject] = useState('');
const [userId, setUserId] = useState('');
const [live, setLive] = useState(true);
const [error, setError] = useState('');
const sinceRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setInterval> | 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 (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Event stream</h1>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-sm text-gray-400 cursor-pointer">
<input type="checkbox" checked={live} onChange={(e) => setLive(e.target.checked)} className="accent-indigo-500" />
Live
</label>
<button onClick={() => { sinceRef.current = 0; fetchEvents(true); }} className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
Refresh
</button>
</div>
</div>
<div className="flex gap-3">
<select value={subject} onChange={(e) => setSubject(e.target.value)} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
{SUBJECTS.map((s) => <option key={s} value={s}>{s || 'All subjects'}</option>)}
</select>
<input
value={userId}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="font-mono text-xs space-y-1 max-h-[70vh] overflow-y-auto">
{events.length === 0 && (
<p className="text-gray-500 text-sm">No events yet. Waiting</p>
)}
{[...events].reverse().map((e) => (
<div key={e.id} className="flex gap-3 border-b border-gray-800 pb-1">
<span className="text-gray-600 w-12 flex-shrink-0">{e.id}</span>
<span className="text-gray-500 w-24 flex-shrink-0">{e.ts.slice(11, 19)}</span>
<span className="text-indigo-400 w-40 flex-shrink-0">{e.subject}</span>
<span className="text-gray-300 break-all">{JSON.stringify(e.payload)}</span>
</div>
))}
</div>
</div>
</AdminShell>
);
}

View File

@@ -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<BanditStats | null>(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 (
<AdminShell>
<div className="space-y-6">
<h1 className="text-xl font-semibold">Experiment dashboard</h1>
<p className="text-sm text-gray-500">LinUCB per-user bandit stats pulled from ml/serving.</p>
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => 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"
/>
<button onClick={fetchStats} className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm">
Load
</button>
{stats && (
<button onClick={handleReset} disabled={resetting} className="bg-red-800 hover:bg-red-700 text-white rounded px-4 py-1.5 text-sm disabled:opacity-50">
Reset bandit
</button>
)}
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{resetMsg && <p className="text-green-400 text-sm">{resetMsg}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{stats && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard label="Pulls" value={stats.pulls} />
<StatCard label="Reward samples" value={stats.reward_count} />
<StatCard label="Cumulative reward" value={stats.cumulative_reward.toFixed(2)} />
<StatCard label="Mean reward" value={stats.estimated_mean_reward.toFixed(3)} />
</div>
)}
{stats?.theta && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">θ (learned weight vector)</h2>
<div className="flex gap-3 flex-wrap">
{stats.theta.map((v, i) => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded p-3 text-center min-w-[100px]">
<div className="text-xs text-gray-500 mb-1">{FEATURE_LABELS[i] ?? `feat_${i}`}</div>
<div className={`text-sm font-mono ${v > 0 ? 'text-green-400' : v < 0 ? 'text-red-400' : 'text-gray-400'}`}>
{v.toFixed(4)}
</div>
</div>
))}
</div>
{stats.last_updated && (
<p className="text-xs text-gray-600">Last updated: {stats.last_updated}</p>
)}
</div>
)}
</div>
</AdminShell>
);
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded p-4">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className="text-2xl font-semibold text-white">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
interface FeatureEntry {
ts: string;
features: Record<string, unknown>;
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<FeatureEntry[]>([]);
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 (
<AdminShell>
<div className="space-y-4">
<h1 className="text-xl font-semibold">Feature store browser</h1>
<p className="text-sm text-gray-500">
Features sent to ml/serving per scoring call for a user. Shows last 100 entries.
</p>
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => 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"
/>
<button onClick={fetch_} className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm">
Load
</button>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{history.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-gray-800 text-gray-500">
<th className="text-left py-2 pr-4">Time</th>
<th className="text-left py-2 pr-4">Score</th>
{FEATURE_NAMES.map((f) => (
<th key={f} className="text-left py-2 pr-4">{f}</th>
))}
<th className="text-left py-2">Tip ID</th>
</tr>
</thead>
<tbody>
{[...history].reverse().map((entry, i) => (
<tr key={i} className="border-b border-gray-800/50">
<td className="py-1.5 pr-4 text-gray-500">{entry.ts.slice(11, 19)}</td>
<td className="py-1.5 pr-4 text-indigo-300">{entry.score.toFixed(4)}</td>
{FEATURE_NAMES.map((f) => (
<td key={f} className="py-1.5 pr-4 text-gray-300">
{String(entry.features[f] ?? '—')}
</td>
))}
<td className="py-1.5 text-gray-500 truncate max-w-xs">{entry.tip_id}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{history.length === 0 && !loading && userId && (
<p className="text-gray-500 text-sm">No scoring history for this user yet.</p>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,10 @@
export default function ForbiddenPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-2">
<h1 className="text-2xl font-semibold">403 Forbidden</h1>
<p className="text-gray-400 text-sm">Your account does not have admin access.</p>
</div>
</div>
);
}

View File

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

View File

@@ -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<string, string> = {
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<HealthStatus | null>(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 (
<AdminShell>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Health</h1>
<div className="flex items-center gap-3">
{health && (
<span className={`text-xs px-2 py-1 rounded border ${health.ok ? 'bg-green-900 text-green-300 border-green-800' : 'bg-red-900 text-red-300 border-red-800'}`}>
{health.ok ? 'All systems operational' : 'Degraded'}
</span>
)}
<button onClick={refresh} className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
Refresh
</button>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && !health && <p className="text-gray-500 text-sm">Checking</p>}
{health && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{health.services.map((svc) => (
<div key={svc.name} className={`rounded border p-4 ${STATUS_STYLES[svc.status] ?? STATUS_STYLES.down}`}>
<div className="text-xs font-medium uppercase tracking-wide mb-1">{svc.name}</div>
<div className="text-lg font-semibold capitalize">{svc.status}</div>
{svc.latencyMs > 0 && (
<div className="text-xs opacity-70 mt-1">{svc.latencyMs}ms</div>
)}
</div>
))}
</div>
<p className="text-xs text-gray-600">Last checked: {health.checkedAt} · auto-refreshes every 15s</p>
</>
)}
</div>
</AdminShell>
);
}

View File

@@ -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 (
<html lang="en" className="dark">
<body className="min-h-screen bg-gray-950 text-gray-100">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,16 @@
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-2xl font-semibold">oO Admin</h1>
<p className="text-gray-400 text-sm">Sign in via the main app first, then return here.</p>
<a
href={`${process.env.NEXT_PUBLIC_WEB_URL ?? 'http://localhost:3079'}/sign-in`}
className="inline-block px-4 py-2 bg-white text-black rounded text-sm font-medium hover:bg-gray-200 transition-colors"
>
Sign in with Google
</a>
</div>
</div>
);
}

View File

@@ -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 (
<AdminShell>
<div className="space-y-4 h-[calc(100vh-4rem)]">
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-xl font-semibold">Model registry</h1>
<a href={mlflowUrl} target="_blank" rel="noreferrer" className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
Open MLflow
</a>
</div>
<p className="text-sm text-gray-500 flex-shrink-0">
MLflow is embedded below when running under the <code className="text-xs bg-gray-800 px-1 rounded">full</code> compose profile.
Promote or archive model versions via the MLflow UI; each action writes to the audit log automatically.
</p>
<div className="flex-1 rounded border border-gray-800 overflow-hidden" style={{ height: 'calc(100vh - 12rem)' }}>
<iframe
src={`${mlflowUrl}/#/models`}
className="w-full h-full bg-white"
title="MLflow Model Registry"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getPolicies, togglePolicy, replaySignal, PolicyInfo } from '@/lib/api';
const VALID_SUBJECTS = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
export default function OpsPage() {
const [policies, setPolicies] = useState<PolicyInfo[]>([]);
const [replaySubject, setReplaySubject] = useState(VALID_SUBJECTS[0]);
const [replayPayload, setReplayPayload] = useState('{\n "userId": "",\n "tipId": ""\n}');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
useEffect(() => {
getPolicies().then((r) => setPolicies(r.policies)).catch(() => {});
}, []);
const handleToggle = async (name: string, active: boolean) => {
try {
await togglePolicy(name, active);
setPolicies((prev) => prev.map((p) => p.name === name ? { ...p, active } : p));
setMsg(`Policy "${name}" ${active ? 'enabled' : 'disabled'}.`);
} catch (e: any) {
setError(e.message);
}
};
const handleReplay = async () => {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(replayPayload);
} catch {
setError('Invalid JSON payload');
return;
}
try {
await replaySignal(replaySubject, payload);
setMsg(`Signal replayed: ${replaySubject}`);
setError('');
} catch (e: any) {
setError(e.message);
}
};
return (
<AdminShell>
<div className="space-y-8">
<h1 className="text-xl font-semibold">Ops actions</h1>
{msg && <p className="text-green-400 text-sm">{msg}</p>}
{error && <p className="text-red-400 text-sm">{error}</p>}
{/* Policy toggles */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">Policies</h2>
{policies.length === 0 ? (
<p className="text-gray-500 text-sm">No shadow policies registered. Shadow policies can be added to the recommender source.</p>
) : (
<div className="space-y-2">
{policies.map((p) => (
<div key={p.name} className="flex items-center justify-between bg-gray-900 border border-gray-800 rounded p-3">
<span className="text-sm text-gray-300 font-mono">{p.name}</span>
<button
onClick={() => handleToggle(p.name, !p.active)}
className={`px-3 py-1 rounded text-xs ${p.active ? 'bg-green-800 text-green-200' : 'bg-gray-800 text-gray-400'}`}
>
{p.active ? 'Active' : 'Disabled'}
</button>
</div>
))}
</div>
)}
</section>
{/* Replay signal */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">Replay signal</h2>
<p className="text-sm text-gray-500">Re-emit a past event on the in-process bus. Useful for backfill and testing.</p>
<div className="space-y-2">
<select
value={replaySubject}
onChange={(e) => setReplaySubject(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-full max-w-sm"
>
{VALID_SUBJECTS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<textarea
value={replayPayload}
onChange={(e) => setReplayPayload(e.target.value)}
rows={6}
className="w-full max-w-xl bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm font-mono text-gray-300"
/>
<button
onClick={handleReplay}
className="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-1.5 text-sm"
>
Replay
</button>
</div>
</section>
{/* User-level ops */}
<section className="space-y-3">
<h2 className="text-base font-medium text-gray-300">User-level actions</h2>
<p className="text-sm text-gray-500">
Revoke integration tokens and reset bandit state are available on the{' '}
<a href="/users" className="text-indigo-400 hover:underline">Users page</a> navigate to a user detail view.
</p>
</section>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { OverviewDashboard } from '@/components/OverviewDashboard';
export const dynamic = 'force-dynamic';
export default function OverviewPage() {
return (
<AdminShell>
<OverviewDashboard />
</AdminShell>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getRewardAnalytics } from '@/lib/api';
const ACTION_COLORS: Record<string, string> = {
done: 'bg-green-500',
helpful: 'bg-teal-500',
snooze: 'bg-yellow-500',
not_helpful: 'bg-orange-500',
dismiss: 'bg-red-500',
};
export default function RewardAnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<Awaited<ReturnType<typeof getRewardAnalytics>> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
getRewardAnalytics(days)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [days]);
// Aggregate totals per action across all days
const totals: Record<string, number> = {};
for (const row of data?.daily ?? []) {
totals[row.action] = (totals[row.action] ?? 0) + Number(row.count);
}
const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0);
// Aggregate per policy
const policyMap: Record<string, Record<string, number>> = {};
for (const row of data?.byPolicy ?? []) {
if (!row.policy) continue;
policyMap[row.policy] ??= {};
if (row.action) policyMap[row.policy][row.action] = (policyMap[row.policy][row.action] ?? 0) + Number(row.count);
}
return (
<AdminShell>
<div className="space-y-6">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">Reward analytics</h1>
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
</select>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{loading && <p className="text-gray-500 text-sm">Loading</p>}
{/* Reaction breakdown bar */}
{grandTotal > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Reaction distribution ({grandTotal} total)</h2>
<div className="flex rounded overflow-hidden h-6">
{Object.entries(totals).map(([action, count]) => (
<div
key={action}
title={`${action}: ${count} (${((count / grandTotal) * 100).toFixed(1)}%)`}
className={`${ACTION_COLORS[action] ?? 'bg-gray-500'} transition-all`}
style={{ width: `${(count / grandTotal) * 100}%` }}
/>
))}
</div>
<div className="flex flex-wrap gap-3 text-xs text-gray-400">
{Object.entries(totals).map(([action, count]) => (
<span key={action} className="flex items-center gap-1">
<span className={`inline-block w-2 h-2 rounded-full ${ACTION_COLORS[action] ?? 'bg-gray-500'}`} />
{action}: {count} ({((count / grandTotal) * 100).toFixed(1)}%)
</span>
))}
</div>
</div>
)}
{/* Per-policy table */}
{Object.keys(policyMap).length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Per-policy reactions</h2>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-4">Policy</th>
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
<th key={a} className="py-2 pr-4">{a}</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(policyMap).map(([policy, actions]) => (
<tr key={policy} className="border-b border-gray-800/50">
<td className="py-2 pr-4 font-medium text-indigo-300">{policy}</td>
{['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
<td key={a} className="py-2 pr-4 text-gray-300">{actions[a] ?? 0}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Daily table */}
{(data?.daily?.length ?? 0) > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-400">Daily breakdown</h2>
<div className="overflow-x-auto max-h-80">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-1.5 pr-4">Date</th>
<th className="py-1.5 pr-4">Action</th>
<th className="py-1.5">Count</th>
</tr>
</thead>
<tbody>
{data!.daily.map((row, i) => (
<tr key={i} className="border-b border-gray-800/40">
<td className="py-1 pr-4 text-gray-500">{row.date}</td>
<td className="py-1 pr-4 text-gray-300">{row.action}</td>
<td className="py-1 text-gray-300">{row.count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!loading && grandTotal === 0 && (
<p className="text-gray-500 text-sm">No reaction data in this period.</p>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { runSql, getSavedQueries, saveQuery, deleteSavedQuery, SavedQuery } from '@/lib/api';
const EXAMPLE_QUERIES = [
'SELECT * FROM users ORDER BY created_at DESC LIMIT 20',
'SELECT action, count(*) as cnt FROM tip_feedback GROUP BY action',
'SELECT policy, count(*) as cnt FROM tip_scores GROUP BY policy',
'SELECT date(served_at) as day, count(*) as tips FROM tip_views GROUP BY day ORDER BY day DESC LIMIT 14',
];
export default function SqlPage() {
const [query, setQuery] = useState(EXAMPLE_QUERIES[0]);
const [rows, setRows] = useState<unknown[]>([]);
const [cols, setCols] = useState<string[]>([]);
const [rowCount, setRowCount] = useState<number | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState('');
const [savedQueries, setSavedQueries] = useState<SavedQuery[]>([]);
const [saveName, setSaveName] = useState('');
useEffect(() => {
getSavedQueries().then((r) => setSavedQueries(r.queries)).catch(() => {});
}, []);
const run = async () => {
if (!query.trim()) return;
setRunning(true);
setError('');
try {
const res = await runSql(query);
const r = res.rows as Record<string, unknown>[];
setRows(r);
setRowCount(res.rowCount);
setCols(r.length > 0 ? Object.keys(r[0] as object) : []);
} catch (e: any) {
setError(e.message);
setRows([]);
setRowCount(null);
} finally {
setRunning(false);
}
};
const handleSave = async () => {
if (!saveName.trim()) return;
await saveQuery(saveName, query);
const res = await getSavedQueries();
setSavedQueries(res.queries);
setSaveName('');
};
const handleDelete = async (id: string) => {
await deleteSavedQuery(id);
setSavedQueries((prev) => prev.filter((q) => q.id !== id));
};
return (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">SQL runner</h1>
<span className="text-xs text-gray-500">Read-only · SELECT only · sunsets in M4</span>
</div>
<div className="flex gap-4">
{/* Editor */}
<div className="flex-1 space-y-2">
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') run(); }}
rows={6}
spellCheck={false}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm font-mono text-gray-200 focus:outline-none focus:border-indigo-500"
placeholder="SELECT ..."
/>
<div className="flex items-center gap-2">
<button
onClick={run}
disabled={running}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded px-4 py-1.5 text-sm"
>
{running ? 'Running…' : 'Run (⌘↵)'}
</button>
<input
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="Save as…"
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-40"
/>
<button onClick={handleSave} className="text-sm text-gray-400 hover:text-white border border-gray-700 rounded px-3 py-1.5">
Save
</button>
</div>
</div>
{/* Saved / examples */}
<div className="w-56 space-y-2 flex-shrink-0">
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide">Saved queries</p>
{savedQueries.length === 0 && (
<p className="text-xs text-gray-600">None saved yet</p>
)}
{savedQueries.map((q) => (
<div key={q.id} className="flex items-start justify-between gap-1">
<button onClick={() => setQuery(q.sql)} className="text-xs text-indigo-400 hover:text-indigo-300 text-left">{q.name}</button>
<button onClick={() => handleDelete(q.id)} className="text-xs text-gray-600 hover:text-red-400"></button>
</div>
))}
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide pt-2">Examples</p>
{EXAMPLE_QUERIES.map((q, i) => (
<button key={i} onClick={() => setQuery(q)} className="block text-xs text-gray-500 hover:text-gray-300 text-left truncate w-full">
{q.slice(0, 40)}
</button>
))}
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{rowCount !== null && (
<p className="text-xs text-gray-500">{rowCount} rows returned</p>
)}
{cols.length > 0 && (
<div className="overflow-auto max-h-[50vh] border border-gray-800 rounded">
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-gray-950">
<tr className="border-b border-gray-800">
{cols.map((c) => (
<th key={c} className="text-left py-2 px-3 text-gray-500 font-medium">{c}</th>
))}
</tr>
</thead>
<tbody>
{(rows as Record<string, unknown>[]).map((row, i) => (
<tr key={i} className="border-b border-gray-800/40 hover:bg-gray-900/40">
{cols.map((c) => (
<td key={c} className="py-1.5 px-3 text-gray-300">{String(row[c] ?? '')}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { AdminShell } from '@/components/AdminShell';
import { getTips, TipScore } from '@/lib/api';
export default function TipsPage() {
const [tips, setTips] = useState<TipScore[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [userId, setUserId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const LIMIT = 50;
const fetch_ = async (off = 0) => {
setLoading(true);
try {
const res = await getTips({ limit: LIMIT, offset: off, userId: userId || undefined });
setTips(res.tips);
setTotal(res.total);
setOffset(off);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => { fetch_(0); }, [userId]);
return (
<AdminShell>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Recommendation log</h1>
<span className="text-sm text-gray-500">{total} total</span>
</div>
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Filter by user ID"
className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-72"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 text-left">
<th className="py-2 pr-3">Served at</th>
<th className="py-2 pr-3">User</th>
<th className="py-2 pr-3">Policy</th>
<th className="py-2 pr-3">Score</th>
<th className="py-2 pr-3">Candidates</th>
<th className="py-2 pr-3">Latency</th>
<th className="py-2">Features</th>
</tr>
</thead>
<tbody>
{tips.map((t) => {
const feats = t.featuresJson ? JSON.parse(t.featuresJson) : null;
return (
<tr key={t.id} className="border-b border-gray-800/50 hover:bg-gray-900/50">
<td className="py-1.5 pr-3 text-gray-500 font-mono">{t.servedAt.slice(0, 19)}</td>
<td className="py-1.5 pr-3 text-gray-400 font-mono truncate max-w-[120px]">{t.userId.slice(0, 8)}</td>
<td className="py-1.5 pr-3">
<span className={`px-1.5 py-0.5 rounded text-xs ${t.policy === 'random' ? 'bg-gray-800 text-gray-400' : 'bg-indigo-900 text-indigo-300'}`}>
{t.policy}
</span>
</td>
<td className="py-1.5 pr-3 font-mono text-gray-300">{t.mlScore != null ? (t.mlScore / 1000).toFixed(3) : '—'}</td>
<td className="py-1.5 pr-3 text-gray-400">{t.candidateCount ?? '—'}</td>
<td className="py-1.5 pr-3 text-gray-400">{t.latencyMs != null ? `${t.latencyMs}ms` : '—'}</td>
<td className="py-1.5 text-gray-500 font-mono text-xs">
{feats ? `p${feats.priority} ${feats.is_overdue ? '⚠' : ''}` : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex gap-3 items-center text-sm">
<button onClick={() => fetch_(offset - LIMIT)} disabled={offset === 0 || loading} className="text-gray-400 hover:text-white disabled:opacity-30"> Prev</button>
<span className="text-gray-600">{offset + 1}{Math.min(offset + LIMIT, total)} of {total}</span>
<button onClick={() => fetch_(offset + LIMIT)} disabled={offset + LIMIT >= total || loading} className="text-gray-400 hover:text-white disabled:opacity-30">Next </button>
</div>
</div>
</AdminShell>
);
}

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { UserDetail } from '@/components/UserDetail';
export const dynamic = 'force-dynamic';
export default function UserDetailPage({ params }: { params: { id: string } }) {
return (
<AdminShell>
<UserDetail userId={params.id} />
</AdminShell>
);
}

View File

@@ -0,0 +1,12 @@
import { AdminShell } from '@/components/AdminShell';
import { UsersTable } from '@/components/UsersTable';
export const dynamic = 'force-dynamic';
export default function UsersPage() {
return (
<AdminShell>
<UsersTable />
</AdminShell>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const NAV = [
{ 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: 'Experiments' },
{ 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' },
];
export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="w-52 flex-shrink-0 border-r border-gray-800 bg-gray-950 flex flex-col">
<div className="px-5 py-4 border-b border-gray-800">
<span className="text-lg font-bold tracking-tight">oO</span>
<span className="ml-2 text-xs text-gray-500 font-medium uppercase tracking-widest">
Admin
</span>
</div>
<nav className="flex-1 px-2 py-3 space-y-0.5">
{NAV.map(({ href, label }) => {
const active = href === '/' ? pathname === '/' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`flex items-center px-3 py-2 rounded text-sm transition-colors ${
active
? 'bg-gray-800 text-white font-medium'
: 'text-gray-400 hover:text-white hover:bg-gray-900'
}`}
>
{label}
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import { getAuditLog, type AuditAction } from '@/lib/api';
const PAGE_SIZE = 50;
export function AuditLog() {
const [rows, setRows] = useState<AuditAction[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
getAuditLog(PAGE_SIZE, offset)
.then(({ actions, total }) => {
setRows(actions);
setTotal(total);
})
.catch((e) => setError(String(e.message)))
.finally(() => setLoading(false));
}, [offset]);
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Audit log</h1>
<span className="text-sm text-gray-500">{total} entries</span>
</div>
<div className="rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
{['Time', 'Admin', 'Action', 'Target'].map((h) => (
<th
key={h}
className="text-left px-4 py-2.5 text-xs text-gray-500 font-medium uppercase tracking-wide"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{loading ? (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-500">
Loading
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-500">
No actions logged yet.
</td>
</tr>
) : (
rows.map((a) => (
<tr key={a.id} className="hover:bg-gray-900 transition-colors">
<td className="px-4 py-2.5 text-xs tabular-nums text-gray-400">
{a.createdAt.slice(0, 19).replace('T', ' ')}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-300 truncate max-w-[8rem]">
{a.adminId.slice(0, 8)}
</td>
<td className="px-4 py-2.5">
<span className="px-1.5 py-0.5 rounded bg-gray-800 text-xs text-gray-200 font-mono">
{a.action}
</span>
</td>
<td className="px-4 py-2.5 text-xs text-gray-400">
{a.targetType && (
<span className="text-gray-500">{a.targetType}: </span>
)}
<span className="font-mono">{a.targetId?.slice(0, 12) ?? '—'}</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div className="flex items-center gap-3 text-sm">
<button
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Previous
</button>
<span className="text-gray-500">
{offset + 1}{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useEffect, useState } from 'react';
import { getStats, type AdminStats } from '@/lib/api';
function KpiCard({
title,
value,
sub,
}: {
title: string;
value: string | number;
sub?: string;
}) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-1">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium">{title}</p>
<p className="text-3xl font-bold tabular-nums">{value}</p>
{sub && <p className="text-xs text-gray-500">{sub}</p>}
</div>
);
}
function ReactionBar({ reactions }: { reactions: Record<string, number> }) {
const total = Object.values(reactions).reduce((a, b) => a + b, 0);
if (total === 0) return <p className="text-sm text-gray-500">No reactions yet.</p>;
const COLORS: Record<string, string> = {
done: 'bg-emerald-500',
snooze: 'bg-yellow-400',
dismiss: 'bg-red-500',
};
return (
<div className="space-y-2">
{Object.entries(reactions).map(([action, count]) => (
<div key={action} className="flex items-center gap-3">
<span className="w-16 text-xs text-gray-400 capitalize">{action}</span>
<div className="flex-1 bg-gray-800 rounded-full h-2">
<div
className={`${COLORS[action] ?? 'bg-gray-500'} h-2 rounded-full transition-all`}
style={{ width: `${((count / total) * 100).toFixed(1)}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-gray-400">{count}</span>
</div>
))}
</div>
);
}
export function OverviewDashboard() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getStats()
.then(setStats)
.catch((e) => setError(String(e.message)));
}, []);
if (error) {
return <p className="text-red-400 text-sm">Failed to load stats: {error}</p>;
}
const activationPct =
stats && stats.totalUsers > 0
? ((stats.activatedUsers / stats.totalUsers) * 100).toFixed(1)
: null;
return (
<div className="space-y-8">
<div>
<h1 className="text-xl font-semibold">Overview</h1>
<p className="text-sm text-gray-500 mt-0.5">Last 7 days unless noted</p>
</div>
{/* KPI grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard title="DAU" value={stats?.dau ?? '—'} sub="unique users today" />
<KpiCard title="WAU" value={stats?.wau ?? '—'} sub="unique users last 7 d" />
<KpiCard
title="Tips served"
value={stats?.tipsServedLast7d ?? '—'}
sub="last 7 days"
/>
<KpiCard
title="Activation"
value={activationPct != null ? `${activationPct}%` : '—'}
sub={
stats
? `${stats.activatedUsers} of ${stats.totalUsers} users`
: 'users who saw ≥1 tip'
}
/>
</div>
{/* Reactions */}
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 max-w-sm">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">
Reactions last 7 days
</p>
{stats ? (
<ReactionBar reactions={stats.reactionsLast7d} />
) : (
<p className="text-sm text-gray-500">Loading</p>
)}
</div>
{/* Activation funnel */}
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 max-w-sm">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">
Activation funnel
</p>
{stats ? (
<div className="space-y-2 text-sm">
<FunnelRow label="Total users" value={stats.totalUsers} max={stats.totalUsers} />
<FunnelRow
label="Saw ≥1 tip"
value={stats.activatedUsers}
max={stats.totalUsers}
/>
<FunnelRow
label="Reacted to tip"
value={Object.values(stats.reactionsLast7d).reduce((a, b) => a + b, 0)}
max={stats.tipsServedLast7d}
dimMax
/>
</div>
) : (
<p className="text-sm text-gray-500">Loading</p>
)}
</div>
</div>
);
}
function FunnelRow({
label,
value,
max,
dimMax,
}: {
label: string;
value: number;
max: number;
dimMax?: boolean;
}) {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div className="flex items-center gap-3">
<span className="w-32 text-gray-400 text-xs">{label}</span>
<div className="flex-1 bg-gray-800 rounded-full h-1.5">
<div
className="bg-indigo-500 h-1.5 rounded-full transition-all"
style={{ width: `${pct.toFixed(1)}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-gray-300">{value}</span>
{!dimMax && max > 0 && (
<span className="text-xs text-gray-600 tabular-nums">{pct.toFixed(0)}%</span>
)}
</div>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import { useEffect, useState } from 'react';
import { getUserDetail, revokeIntegration, resetBandit, type AdminUserDetail } from '@/lib/api';
export function UserDetail({ userId }: { userId: string }) {
const [data, setData] = useState<AdminUserDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); // which action is running
useEffect(() => {
getUserDetail(userId)
.then(setData)
.catch((e) => setError(String(e.message)));
}, [userId]);
async function handleRevoke(provider: string) {
if (!confirm(`Revoke ${provider} for this user?`)) return;
setBusy(`revoke:${provider}`);
try {
await revokeIntegration(userId, provider);
setData((d) =>
d
? { ...d, integrations: d.integrations.filter((i) => i.provider !== provider) }
: d,
);
} catch (e: unknown) {
alert(`Failed: ${(e as Error).message}`);
} finally {
setBusy(null);
}
}
async function handleResetBandit() {
if (!confirm("Reset this user's LinUCB model? Their personalization will start over.")) return;
setBusy('bandit');
try {
await resetBandit(userId);
alert('Bandit reset.');
} catch (e: unknown) {
alert(`Failed: ${(e as Error).message}`);
} finally {
setBusy(null);
}
}
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
if (!data) return <p className="text-gray-500 text-sm">Loading</p>;
const { user, integrations, tipsServed, lastTipAt, recentFeedback } = data;
return (
<div className="space-y-6 max-w-2xl">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">{user.name ?? user.email}</h1>
<p className="text-sm text-gray-400 mt-0.5">{user.email}</p>
</div>
<div className="flex gap-2">
<button
onClick={handleResetBandit}
disabled={busy === 'bandit'}
className="px-3 py-1.5 text-xs rounded border border-gray-700 hover:border-red-600 hover:text-red-400 transition-colors disabled:opacity-40"
>
{busy === 'bandit' ? 'Resetting…' : 'Reset bandit'}
</button>
</div>
</div>
{/* Identity */}
<Section title="Identity">
<Row label="ID" value={user.id} mono />
<Row label="Role" value={user.role} />
<Row label="Consent" value={user.consentGiven ? `yes (${user.consentAt?.slice(0, 10)})` : 'no'} />
<Row label="Joined" value={user.createdAt.slice(0, 10)} />
{user.deletedAt && <Row label="Deleted" value={user.deletedAt.slice(0, 10)} />}
</Section>
{/* Integrations */}
<Section title="Integrations">
{integrations.length === 0 ? (
<p className="text-sm text-gray-500">No integrations connected.</p>
) : (
integrations.map((i) => (
<div key={i.provider} className="flex items-center justify-between py-1">
<div>
<span className="text-sm capitalize">{i.provider}</span>
<span className="ml-2 text-xs text-gray-500">
connected {i.connectedAt.slice(0, 10)}
</span>
</div>
<button
onClick={() => handleRevoke(i.provider)}
disabled={busy === `revoke:${i.provider}`}
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-40"
>
{busy === `revoke:${i.provider}` ? 'Revoking…' : 'Revoke'}
</button>
</div>
))
)}
</Section>
{/* Tip stats */}
<Section title="Tip activity">
<Row label="Tips served (all time)" value={String(tipsServed)} />
<Row label="Last tip" value={lastTipAt?.slice(0, 19).replace('T', ' ') ?? '—'} />
</Section>
{/* Feedback history */}
<Section title="Recent feedback">
{recentFeedback.length === 0 ? (
<p className="text-sm text-gray-500">No feedback recorded.</p>
) : (
<div className="space-y-1">
{recentFeedback.map((f) => (
<div key={f.id} className="flex items-center gap-4 text-sm">
<span
className={`w-16 text-xs font-medium ${
f.action === 'done'
? 'text-emerald-400'
: f.action === 'snooze'
? 'text-yellow-400'
: 'text-red-400'
}`}
>
{f.action}
</span>
<span className="text-gray-500 text-xs tabular-nums">
{f.createdAt.slice(0, 19).replace('T', ' ')}
</span>
<span className="text-gray-600 text-xs font-mono truncate">{f.tipId}</span>
</div>
))}
</div>
)}
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">{title}</p>
{children}
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline gap-3 text-sm">
<span className="w-36 flex-shrink-0 text-gray-500">{label}</span>
<span className={mono ? 'font-mono text-xs text-gray-300' : 'text-gray-200'}>{value}</span>
</div>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { getUsers, type AdminUser } from '@/lib/api';
const PAGE_SIZE = 50;
export function UsersTable() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
getUsers(PAGE_SIZE, offset)
.then(({ users, total }) => {
setUsers(users);
setTotal(total);
})
.catch((e) => setError(String(e.message)))
.finally(() => setLoading(false));
}, [offset]);
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Users</h1>
<span className="text-sm text-gray-500">{total} total</span>
</div>
<div className="rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
{['Email', 'Name', 'Role', 'Consent', 'Joined', 'Status'].map((h) => (
<th
key={h}
className="text-left px-4 py-2.5 text-xs text-gray-500 font-medium uppercase tracking-wide"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-6 text-center text-gray-500">
Loading
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-6 text-center text-gray-500">
No users yet.
</td>
</tr>
) : (
users.map((u) => (
<tr
key={u.id}
className="hover:bg-gray-900 transition-colors cursor-pointer"
>
<td className="px-4 py-2.5">
<Link href={`/users/${u.id}`} className="hover:underline text-indigo-400">
{u.email}
</Link>
</td>
<td className="px-4 py-2.5 text-gray-300">{u.name ?? '—'}</td>
<td className="px-4 py-2.5">
<span
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
u.role === 'admin'
? 'bg-indigo-900 text-indigo-300'
: 'bg-gray-800 text-gray-400'
}`}
>
{u.role}
</span>
</td>
<td className="px-4 py-2.5">
{u.consentGiven ? (
<span className="text-emerald-400 text-xs">yes</span>
) : (
<span className="text-gray-600 text-xs">no</span>
)}
</td>
<td className="px-4 py-2.5 text-gray-400 text-xs tabular-nums">
{u.createdAt.slice(0, 10)}
</td>
<td className="px-4 py-2.5">
{u.deletedAt ? (
<span className="text-red-500 text-xs">deleted</span>
) : (
<span className="text-emerald-500 text-xs">active</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="flex items-center gap-3 text-sm">
<button
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Previous
</button>
<span className="text-gray-500">
{offset + 1}{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}

222
apps/admin/src/lib/api.ts Normal file
View File

@@ -0,0 +1,222 @@
const API = '/api';
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
credentials: 'include',
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
}
return res.json() as T;
}
// ── Types ──────────────────────────────────────────────────────────────────
export interface AdminStats {
dau: number;
wau: number;
tipsServedLast7d: number;
reactionsLast7d: Record<string, number>;
totalUsers: number;
activatedUsers: number;
}
export interface AdminUser {
id: string;
email: string;
name: string | null;
image: string | null;
role: string;
consentGiven: boolean;
consentAt: string | null;
createdAt: string;
deletedAt: string | null;
}
export interface AdminUserDetail {
user: AdminUser;
integrations: { provider: string; connectedAt: string }[];
tipsServed: number;
lastTipAt: string | null;
recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[];
}
export interface AuditAction {
id: string;
adminId: string;
action: string;
targetType: string | null;
targetId: string | null;
detail: string | null;
createdAt: string;
}
export interface StoredEvent {
id: number;
subject: string;
payload: unknown;
ts: string;
}
export interface TipScore {
id: string;
userId: string;
tipId: string;
policy: string;
mlScore: number | null;
featuresJson: string | null;
candidateCount: number | null;
latencyMs: number | null;
servedAt: string;
}
export interface HealthStatus {
ok: boolean;
checkedAt: string;
services: { name: string; status: string; latencyMs: number }[];
}
export interface PolicyInfo {
name: string;
active: boolean;
}
export interface SavedQuery {
id: string;
name: string;
sql: string;
createdAt: string;
}
export interface BanditStats {
user_id: string;
pulls: number;
reward_count: number;
cumulative_reward: number;
estimated_mean_reward: number;
theta: number[];
last_updated: string | null;
}
export interface FeatureHistory {
user_id: string;
history: { ts: string; features: Record<string, unknown>; score: number; tip_id: string }[];
}
// ── Fetchers ───────────────────────────────────────────────────────────────
export function getStats() {
return apiFetch<AdminStats>('/admin/stats');
}
export function getUsers(limit = 50, offset = 0) {
return apiFetch<{ users: AdminUser[]; total: number }>(
`/admin/users?limit=${limit}&offset=${offset}`,
);
}
export function getUserDetail(id: string) {
return apiFetch<AdminUserDetail>(`/admin/users/${id}`);
}
export function revokeIntegration(userId: string, provider: string) {
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/revoke-integration`, {
method: 'POST',
body: JSON.stringify({ provider }),
});
}
export function resetBandit(userId: string) {
return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/reset-bandit`, {
method: 'POST',
});
}
export function getAuditLog(limit = 50, offset = 0) {
return apiFetch<{ actions: AuditAction[]; total: number }>(
`/admin/audit?limit=${limit}&offset=${offset}`,
);
}
export function getEvents(params: { subject?: string; userId?: string; limit?: number; since?: number } = {}) {
const q = new URLSearchParams();
if (params.subject) q.set('subject', params.subject);
if (params.userId) q.set('userId', params.userId);
if (params.limit) q.set('limit', String(params.limit));
if (params.since) q.set('since', String(params.since));
return apiFetch<{ events: StoredEvent[]; nextSince: number }>(`/admin/events?${q}`);
}
export function getTips(params: { limit?: number; offset?: number; userId?: string } = {}) {
const q = new URLSearchParams();
if (params.limit) q.set('limit', String(params.limit));
if (params.offset) q.set('offset', String(params.offset));
if (params.userId) q.set('userId', params.userId);
return apiFetch<{ tips: TipScore[]; total: number }>(`/admin/tips?${q}`);
}
export function getRewardAnalytics(days = 30) {
return apiFetch<{
daily: { date: string; action: string; count: number }[];
byPolicy: { policy: string; action: string; count: number }[];
byHour: { action: string; count: number; avgHour: number }[];
}>(`/admin/reward-analytics?days=${days}`);
}
export function getDataQuality() {
return apiFetch<{
scoringCallsLast30d: number;
missingFeatureRate: number;
staleTokenRate: number;
totalTokens: number;
staleTokens: number;
dailyQuality: { date: string; total: number; withFeatures: number; avgCandidates: number }[];
}>('/admin/data-quality');
}
export function getHealth() {
return apiFetch<HealthStatus>('/admin/health');
}
export function getPolicies() {
return apiFetch<{ policies: PolicyInfo[] }>('/admin/policies');
}
export function togglePolicy(name: string, active: boolean) {
return apiFetch<{ ok: boolean }>(`/admin/policies/${name}/toggle`, {
method: 'POST',
body: JSON.stringify({ active }),
});
}
export function replaySignal(subject: string, payload: Record<string, unknown>) {
return apiFetch<{ ok: boolean }>('/admin/replay-signal', {
method: 'POST',
body: JSON.stringify({ subject, payload }),
});
}
export function runSql(query: string) {
return apiFetch<{ rows: unknown[]; rowCount: number }>('/admin/sql', {
method: 'POST',
body: JSON.stringify({ query }),
});
}
export function getSavedQueries() {
return apiFetch<{ queries: SavedQuery[] }>('/admin/saved-queries');
}
export function saveQuery(name: string, querySql: string) {
return apiFetch<{ id: string }>('/admin/saved-queries', {
method: 'POST',
body: JSON.stringify({ name, querySql }),
});
}
export function deleteSavedQuery(id: string) {
return apiFetch<{ ok: boolean }>(`/admin/saved-queries/${id}`, { method: 'DELETE' });
}

119
apps/admin/src/lib/docs.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* Server-side utilities for reading project documentation from the monorepo
* `docs/` directory and rendering it as HTML.
*
* All functions are async and must only be called from server components or
* server actions (no 'use client' imports of this module).
*
* Directory layout relative to monorepo root:
* docs/adr/ — Architecture Decision Records (NNN-title.md)
* docs/architecture/ — longer architecture notes (topic.md)
*/
import { readdir, readFile } from 'fs/promises';
import path from 'path';
import { marked } from 'marked';
// apps/admin sits two levels below the monorepo root.
const DOCS_ROOT = path.resolve(process.cwd(), '../../docs');
export type DocCategory = 'adr' | 'architecture';
export interface DocMeta {
category: DocCategory;
slug: string; // filename without .md
title: string; // first H1 from the file, fallback = slug
href: string; // /docs/adr/0001-monorepo-polyglot
status?: string; // for ADRs: "Accepted", "Proposed", …
date?: string; // for ADRs: date after em-dash on Status line
}
export interface DocPage extends DocMeta {
html: string;
}
// ---------------------------------------------------------------------------
// internal helpers
// ---------------------------------------------------------------------------
/** Extract the first # heading from markdown. */
function extractTitle(md: string): string {
const m = md.match(/^#\s+(.+)$/m);
return m ? m[1].trim() : '';
}
/** Extract status + date from "## Status\nAccepted — 2026-04-13" pattern. */
function extractStatus(md: string): { status?: string; date?: string } {
const block = md.match(/##\s+Status\s*\n+([^\n#]+)/);
if (!block) return {};
const line = block[1].trim();
// "Accepted — 2026-04-13" or "Proposed"
const parts = line.split(/\s*[–—]\s*/);
return { status: parts[0]?.trim(), date: parts[1]?.trim() };
}
function slugFromFile(filename: string): string {
return filename.replace(/\.md$/, '');
}
// ---------------------------------------------------------------------------
// public API
// ---------------------------------------------------------------------------
/** List all docs in a category, sorted by filename. */
export async function listDocs(category: DocCategory): Promise<DocMeta[]> {
const dir = path.join(DOCS_ROOT, category);
let files: string[];
try {
files = (await readdir(dir)).filter((f) => f.endsWith('.md')).sort();
} catch {
return [];
}
return Promise.all(
files.map(async (file) => {
const slug = slugFromFile(file);
const md = await readFile(path.join(dir, file), 'utf8');
const title = extractTitle(md) || slug;
const { status, date } = extractStatus(md);
return {
category,
slug,
title,
href: `/docs/${category}/${slug}`,
status,
date,
};
}),
);
}
/** List all docs across all categories. */
export async function listAllDocs(): Promise<Record<DocCategory, DocMeta[]>> {
const [adr, architecture] = await Promise.all([listDocs('adr'), listDocs('architecture')]);
return { adr, architecture };
}
/** Read and render a single doc to HTML. */
export async function getDoc(category: DocCategory, slug: string): Promise<DocPage | null> {
const file = path.join(DOCS_ROOT, category, `${slug}.md`);
let md: string;
try {
md = await readFile(file, 'utf8');
} catch {
return null;
}
const title = extractTitle(md) || slug;
const { status, date } = extractStatus(md);
const html = await marked(md, { gfm: true });
return {
category,
slug,
title,
href: `/docs/${category}/${slug}`,
status,
date,
html,
};
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Pass through the login page and API calls
if (pathname.startsWith('/login') || pathname.startsWith('/api/')) {
return NextResponse.next();
}
const sid = req.cookies.get('sid')?.value;
if (!sid) {
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
// Verify admin role via API. The API is same-origin in production (Caddy routes
// /api/* to the Express service), so we use the rewrite target in dev.
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3078';
try {
const profile = await fetch(`${apiBase}/api/user/me`, {
headers: { cookie: `sid=${sid}` },
next: { revalidate: 0 },
});
if (!profile.ok) throw new Error('not ok');
const data = (await profile.json()) as { role?: string };
if (data.role !== 'admin') {
const url = req.nextUrl.clone();
url.pathname = '/forbidden';
return NextResponse.redirect(url);
}
} catch {
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};