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:
165
apps/admin/src/components/OverviewDashboard.tsx
Normal file
165
apps/admin/src/components/OverviewDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user