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>
166 lines
5.0 KiB
TypeScript
166 lines
5.0 KiB
TypeScript
'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>
|
|
);
|
|
}
|