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:
12
apps/admin/src/app/audit/page.tsx
Normal file
12
apps/admin/src/app/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/admin/src/app/data-quality/page.tsx
Normal file
89
apps/admin/src/app/data-quality/page.tsx
Normal 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 (>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>
|
||||
);
|
||||
}
|
||||
73
apps/admin/src/app/docs/[category]/[slug]/page.tsx
Normal file
73
apps/admin/src/app/docs/[category]/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
apps/admin/src/app/docs/page.tsx
Normal file
82
apps/admin/src/app/docs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/admin/src/app/events/page.tsx
Normal file
93
apps/admin/src/app/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
apps/admin/src/app/experiments/page.tsx
Normal file
124
apps/admin/src/app/experiments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
apps/admin/src/app/features/page.tsx
Normal file
98
apps/admin/src/app/features/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/admin/src/app/forbidden/page.tsx
Normal file
10
apps/admin/src/app/forbidden/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
apps/admin/src/app/globals.css
Normal file
123
apps/admin/src/app/globals.css
Normal 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; }
|
||||
71
apps/admin/src/app/health/page.tsx
Normal file
71
apps/admin/src/app/health/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/admin/src/app/layout.tsx
Normal file
15
apps/admin/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/admin/src/app/login/page.tsx
Normal file
16
apps/admin/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/admin/src/app/models/page.tsx
Normal file
30
apps/admin/src/app/models/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/admin/src/app/ops/page.tsx
Normal file
114
apps/admin/src/app/ops/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/admin/src/app/page.tsx
Normal file
12
apps/admin/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/admin/src/app/reward-analytics/page.tsx
Normal file
144
apps/admin/src/app/reward-analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
apps/admin/src/app/sql/page.tsx
Normal file
152
apps/admin/src/app/sql/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
apps/admin/src/app/tips/page.tsx
Normal file
97
apps/admin/src/app/tips/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/admin/src/app/users/[id]/page.tsx
Normal file
12
apps/admin/src/app/users/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/admin/src/app/users/page.tsx
Normal file
12
apps/admin/src/app/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user