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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/admin/src/components/AdminShell.tsx
Normal file
58
apps/admin/src/components/AdminShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/admin/src/components/AuditLog.tsx
Normal file
112
apps/admin/src/components/AuditLog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
apps/admin/src/components/UserDetail.tsx
Normal file
159
apps/admin/src/components/UserDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
apps/admin/src/components/UsersTable.tsx
Normal file
134
apps/admin/src/components/UsersTable.tsx
Normal 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
222
apps/admin/src/lib/api.ts
Normal 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
119
apps/admin/src/lib/docs.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
apps/admin/src/middleware.ts
Normal file
45
apps/admin/src/middleware.ts
Normal 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).*)'],
|
||||||
|
};
|
||||||
@@ -86,12 +86,17 @@ export default function TipPage() {
|
|||||||
} catch { setPushState('denied'); }
|
} catch { setPushState('denied'); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
|
const react = async (action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful') => {
|
||||||
if (!tip) return;
|
if (!tip) return;
|
||||||
|
const isNavigating = ['done', 'dismiss', 'snooze'].includes(action);
|
||||||
|
if (isNavigating) {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
setState('done');
|
setState('done');
|
||||||
|
} else {
|
||||||
|
setState('tip');
|
||||||
|
}
|
||||||
await sendFeedback(tip.id, { action });
|
await sendFeedback(tip.id, { action });
|
||||||
setTimeout(() => loadTip(), 700);
|
if (isNavigating) setTimeout(() => loadTip(), 700);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerDown = () => {
|
const onPointerDown = () => {
|
||||||
@@ -269,6 +274,8 @@ export default function TipPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
|
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
|
||||||
|
<ActionButton label="Helpful" onClick={() => react('helpful')} />
|
||||||
|
<ActionButton label="Not helpful" onClick={() => react('not_helpful')} />
|
||||||
<ActionButton label="Snooze" onClick={() => react('snooze')} />
|
<ActionButton label="Snooze" onClick={() => react('snooze')} />
|
||||||
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
|
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ oO ML Serving — Phase 1: LinUCB contextual bandit.
|
|||||||
Contract:
|
Contract:
|
||||||
POST /score { user_id, candidates, context } → { tip_id, score, policy }
|
POST /score { user_id, candidates, context } → { tip_id, score, policy }
|
||||||
POST /reward { user_id, tip_id, reward, features } → { ok }
|
POST /reward { user_id, tip_id, reward, features } → { ok }
|
||||||
|
POST /reset/{user_id} → { ok }
|
||||||
|
GET /stats/{user_id} → { pulls, cumulative_reward, estimated_mean, last_updated }
|
||||||
|
GET /features/{user_id} → { history: [{ ts, features, score }] }
|
||||||
GET /health → { ok }
|
GET /health → { ok }
|
||||||
|
|
||||||
Features (d=5):
|
Features (d=5):
|
||||||
@@ -18,9 +21,10 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import random
|
import time
|
||||||
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Deque
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
@@ -33,6 +37,16 @@ STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|||||||
|
|
||||||
ALPHA = 1.0 # exploration coefficient
|
ALPHA = 1.0 # exploration coefficient
|
||||||
D = 5 # feature dimension
|
D = 5 # feature dimension
|
||||||
|
FEATURE_HISTORY_SIZE = 100 # per-user ring buffer
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-user in-memory feature history ────────────────────────────────────
|
||||||
|
_feature_history: dict[str, deque] = {}
|
||||||
|
|
||||||
|
def get_feature_history(user_id: str) -> deque:
|
||||||
|
if user_id not in _feature_history:
|
||||||
|
_feature_history[user_id] = deque(maxlen=FEATURE_HISTORY_SIZE)
|
||||||
|
return _feature_history[user_id]
|
||||||
|
|
||||||
|
|
||||||
# ── Feature helpers ────────────────────────────────────────────────────────
|
# ── Feature helpers ────────────────────────────────────────────────────────
|
||||||
@@ -54,20 +68,21 @@ def state_path(user_id: str) -> Path:
|
|||||||
return STATE_DIR / f"{safe}.json"
|
return STATE_DIR / f"{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray]:
|
def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray, dict]:
|
||||||
"""Returns (A, b). A is DxD, b is D-vector."""
|
"""Returns (A, b, meta). A is DxD, b is D-vector."""
|
||||||
p = state_path(user_id)
|
p = state_path(user_id)
|
||||||
if p.exists():
|
if p.exists():
|
||||||
raw = json.loads(p.read_text())
|
raw = json.loads(p.read_text())
|
||||||
A = np.array(raw["A"], dtype=np.float64)
|
A = np.array(raw["A"], dtype=np.float64)
|
||||||
b = np.array(raw["b"], dtype=np.float64)
|
b = np.array(raw["b"], dtype=np.float64)
|
||||||
return A, b
|
meta = raw.get("meta", {})
|
||||||
return np.identity(D, dtype=np.float64), np.zeros(D, dtype=np.float64)
|
return A, b, meta
|
||||||
|
return np.identity(D, dtype=np.float64), np.zeros(D, dtype=np.float64), {}
|
||||||
|
|
||||||
|
|
||||||
def save_state(user_id: str, A: np.ndarray, b: np.ndarray) -> None:
|
def save_state(user_id: str, A: np.ndarray, b: np.ndarray, meta: dict) -> None:
|
||||||
p = state_path(user_id)
|
p = state_path(user_id)
|
||||||
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist()}))
|
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist(), "meta": meta}))
|
||||||
|
|
||||||
|
|
||||||
# ── API models ─────────────────────────────────────────────────────────────
|
# ── API models ─────────────────────────────────────────────────────────────
|
||||||
@@ -107,7 +122,7 @@ class ScoreResponse(BaseModel):
|
|||||||
class RewardRequest(BaseModel):
|
class RewardRequest(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
tip_id: str
|
tip_id: str
|
||||||
reward: float # +1 done, 0 snooze, -1 dismiss
|
reward: float # +1 done, +0.5 helpful, 0 snooze, -0.5 not_helpful, -1 dismiss
|
||||||
features: CandidateFeatures
|
features: CandidateFeatures
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +142,7 @@ def score(req: ScoreRequest) -> ScoreResponse:
|
|||||||
if not req.candidates:
|
if not req.candidates:
|
||||||
raise HTTPException(status_code=422, detail="No candidates")
|
raise HTTPException(status_code=422, detail="No candidates")
|
||||||
|
|
||||||
A, b = load_state(req.user_id)
|
A, b, meta = load_state(req.user_id)
|
||||||
try:
|
try:
|
||||||
A_inv = np.linalg.inv(A)
|
A_inv = np.linalg.inv(A)
|
||||||
except np.linalg.LinAlgError:
|
except np.linalg.LinAlgError:
|
||||||
@@ -137,6 +152,7 @@ def score(req: ScoreRequest) -> ScoreResponse:
|
|||||||
|
|
||||||
best_id = None
|
best_id = None
|
||||||
best_score = -float("inf")
|
best_score = -float("inf")
|
||||||
|
best_features: dict = {}
|
||||||
|
|
||||||
for candidate in req.candidates:
|
for candidate in req.candidates:
|
||||||
feat_dict = {
|
feat_dict = {
|
||||||
@@ -152,13 +168,28 @@ def score(req: ScoreRequest) -> ScoreResponse:
|
|||||||
if ucb > best_score:
|
if ucb > best_score:
|
||||||
best_score = ucb
|
best_score = ucb
|
||||||
best_id = candidate.id
|
best_id = candidate.id
|
||||||
|
best_features = feat_dict
|
||||||
|
|
||||||
|
# Log to feature history ring buffer
|
||||||
|
history = get_feature_history(req.user_id)
|
||||||
|
history.append({
|
||||||
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"features": best_features,
|
||||||
|
"score": best_score,
|
||||||
|
"tip_id": best_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update meta stats
|
||||||
|
meta["pulls"] = meta.get("pulls", 0) + 1
|
||||||
|
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
save_state(req.user_id, A, b, meta)
|
||||||
|
|
||||||
return ScoreResponse(tip_id=best_id, score=best_score, policy="linucb-v1")
|
return ScoreResponse(tip_id=best_id, score=best_score, policy="linucb-v1")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/reward", response_model=RewardResponse)
|
@app.post("/reward", response_model=RewardResponse)
|
||||||
def reward(req: RewardRequest) -> RewardResponse:
|
def reward(req: RewardRequest) -> RewardResponse:
|
||||||
A, b = load_state(req.user_id)
|
A, b, meta = load_state(req.user_id)
|
||||||
feat_dict = {
|
feat_dict = {
|
||||||
"hour_of_day": req.features.hour_of_day,
|
"hour_of_day": req.features.hour_of_day,
|
||||||
"is_overdue": req.features.is_overdue,
|
"is_overdue": req.features.is_overdue,
|
||||||
@@ -168,5 +199,58 @@ def reward(req: RewardRequest) -> RewardResponse:
|
|||||||
x = build_feature_vector(feat_dict)
|
x = build_feature_vector(feat_dict)
|
||||||
A += np.outer(x, x)
|
A += np.outer(x, x)
|
||||||
b += req.reward * x
|
b += req.reward * x
|
||||||
save_state(req.user_id, A, b)
|
|
||||||
|
# Track cumulative reward in meta
|
||||||
|
meta["cumulative_reward"] = meta.get("cumulative_reward", 0.0) + req.reward
|
||||||
|
meta["reward_count"] = meta.get("reward_count", 0) + 1
|
||||||
|
meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
save_state(req.user_id, A, b, meta)
|
||||||
return RewardResponse(ok=True)
|
return RewardResponse(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/reset/{user_id}", response_model=RewardResponse)
|
||||||
|
def reset(user_id: str) -> RewardResponse:
|
||||||
|
"""Reset per-user bandit state (admin action)."""
|
||||||
|
p = state_path(user_id)
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
if user_id in _feature_history:
|
||||||
|
_feature_history[user_id].clear()
|
||||||
|
return RewardResponse(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stats/{user_id}")
|
||||||
|
def stats(user_id: str):
|
||||||
|
"""Return current LinUCB state summary for a user."""
|
||||||
|
A, b, meta = load_state(user_id)
|
||||||
|
try:
|
||||||
|
A_inv = np.linalg.inv(A)
|
||||||
|
theta = (A_inv @ b).tolist()
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
theta = [0.0] * D
|
||||||
|
|
||||||
|
pulls = meta.get("pulls", 0)
|
||||||
|
cumulative_reward = meta.get("cumulative_reward", 0.0)
|
||||||
|
reward_count = meta.get("reward_count", 0)
|
||||||
|
estimated_mean = cumulative_reward / reward_count if reward_count > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"pulls": pulls,
|
||||||
|
"reward_count": reward_count,
|
||||||
|
"cumulative_reward": cumulative_reward,
|
||||||
|
"estimated_mean_reward": estimated_mean,
|
||||||
|
"theta": theta,
|
||||||
|
"last_updated": meta.get("last_updated"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/features/{user_id}")
|
||||||
|
def features(user_id: str):
|
||||||
|
"""Return recent feature vectors logged at scoring time."""
|
||||||
|
history = get_feature_history(user_id)
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"history": list(history),
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ export interface RecommendResponse {
|
|||||||
|
|
||||||
/** POST /tip/:id/feedback request body */
|
/** POST /tip/:id/feedback request body */
|
||||||
export interface TipFeedback {
|
export interface TipFeedback {
|
||||||
action: 'done' | 'dismiss' | 'snooze';
|
action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful';
|
||||||
snoozedUntil?: string; // ISO 8601, required when action = snooze
|
snoozedUntil?: string; // ISO 8601, required when action = snooze
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ sqlite.pragma('journal_mode = WAL');
|
|||||||
sqlite.pragma('foreign_keys = ON');
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
// Raw sqlite client — used by the SQL runner endpoint.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const rawSqlite: any = sqlite;
|
||||||
|
|
||||||
export function runMigrations() {
|
export function runMigrations() {
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
@@ -17,6 +20,7 @@ export function runMigrations() {
|
|||||||
name TEXT,
|
name TEXT,
|
||||||
image TEXT,
|
image TEXT,
|
||||||
google_id TEXT UNIQUE,
|
google_id TEXT UNIQUE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
consent_given INTEGER NOT NULL DEFAULT 0,
|
consent_given INTEGER NOT NULL DEFAULT 0,
|
||||||
consent_at TEXT,
|
consent_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@@ -43,11 +47,72 @@ export function runMigrations() {
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tip_views (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
tip_id TEXT NOT NULL,
|
||||||
|
served_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_actions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tip_scores (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
tip_id TEXT NOT NULL,
|
||||||
|
policy TEXT NOT NULL,
|
||||||
|
ml_score INTEGER,
|
||||||
|
features_json TEXT,
|
||||||
|
candidate_count INTEGER,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
served_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_queries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sql TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Additive column migrations — safe to run on existing DBs.
|
||||||
|
// SQLite doesn't support IF NOT EXISTS on ALTER TABLE; we ignore the error if already present.
|
||||||
|
for (const stmt of [
|
||||||
|
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`,
|
||||||
|
`ALTER TABLE push_subscriptions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
|
||||||
|
]) {
|
||||||
|
try { sqlite.exec(stmt); } catch { /* column already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed first admin from env (ADMIN_SEED_EMAIL).
|
||||||
|
const seedEmail = process.env.ADMIN_SEED_EMAIL;
|
||||||
|
if (seedEmail) {
|
||||||
|
sqlite.prepare(`UPDATE users SET role = 'admin' WHERE email = ? AND role = 'user'`).run(seedEmail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const users = sqliteTable('users', {
|
|||||||
name: text('name'),
|
name: text('name'),
|
||||||
image: text('image'),
|
image: text('image'),
|
||||||
googleId: text('google_id').unique(),
|
googleId: text('google_id').unique(),
|
||||||
|
role: text('role').notNull().default('user'), // 'user' | 'admin'
|
||||||
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
|
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
|
||||||
consentAt: text('consent_at'),
|
consentAt: text('consent_at'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
@@ -54,3 +55,37 @@ export const sessions = sqliteTable('sessions', {
|
|||||||
expiresAt: text('expires_at').notNull(),
|
expiresAt: text('expires_at').notNull(),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit log — every admin write action is appended here.
|
||||||
|
export const adminActions = sqliteTable('admin_actions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
adminId: text('admin_id').notNull().references(() => users.id),
|
||||||
|
action: text('action').notNull(), // e.g. 'revoke_token', 'reset_bandit'
|
||||||
|
targetType: text('target_type'), // e.g. 'user', 'integration'
|
||||||
|
targetId: text('target_id'),
|
||||||
|
detail: text('detail'), // JSON blob for extra context
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recommendation explainability log — one row per tip served.
|
||||||
|
// features/scores are JSON blobs. Retained 30 days (GDPR).
|
||||||
|
export const tipScores = sqliteTable('tip_scores', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
tipId: text('tip_id').notNull(),
|
||||||
|
policy: text('policy').notNull(),
|
||||||
|
mlScore: integer('ml_score', { mode: 'number' }), // null when random fallback
|
||||||
|
featuresJson: text('features_json'), // JSON: { is_overdue, task_age_days, priority, hour_of_day, day_of_week }
|
||||||
|
candidateCount: integer('candidate_count'),
|
||||||
|
latencyMs: integer('latency_ms'),
|
||||||
|
servedAt: text('served_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin saved SQL queries.
|
||||||
|
export const savedQueries = sqliteTable('saved_queries', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
adminId: text('admin_id').notNull().references(() => users.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
sql: text('sql').notNull(),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type TipServedEvent = {
|
|||||||
export type TipFeedbackEvent = {
|
export type TipFeedbackEvent = {
|
||||||
userId: string;
|
userId: string;
|
||||||
tipId: string;
|
tipId: string;
|
||||||
action: 'done' | 'dismiss' | 'snooze';
|
action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful';
|
||||||
reward: number;
|
reward: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
@@ -39,14 +39,56 @@ type EventMap = {
|
|||||||
'signals.task.synced': TaskSyncedEvent;
|
'signals.task.synced': TaskSyncedEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StoredEvent = {
|
||||||
|
id: number;
|
||||||
|
subject: string;
|
||||||
|
payload: unknown;
|
||||||
|
ts: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RING_SIZE = 500;
|
||||||
|
|
||||||
class Bus extends EventEmitter {
|
class Bus extends EventEmitter {
|
||||||
|
private ring: StoredEvent[] = [];
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
publish<K extends keyof EventMap>(subject: K, payload: EventMap[K]): void {
|
publish<K extends keyof EventMap>(subject: K, payload: EventMap[K]): void {
|
||||||
|
const entry: StoredEvent = {
|
||||||
|
id: ++this.seq,
|
||||||
|
subject,
|
||||||
|
payload,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (this.ring.length >= RING_SIZE) this.ring.shift();
|
||||||
|
this.ring.push(entry);
|
||||||
this.emit(subject, payload);
|
this.emit(subject, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe<K extends keyof EventMap>(subject: K, handler: (payload: EventMap[K]) => void): void {
|
subscribe<K extends keyof EventMap>(subject: K, handler: (payload: EventMap[K]) => void): void {
|
||||||
this.on(subject, handler);
|
this.on(subject, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return recent events from the ring buffer.
|
||||||
|
* @param subject optional filter (prefix match, e.g. "signals.tip")
|
||||||
|
* @param userId optional user ID filter
|
||||||
|
* @param limit max events to return (default 100)
|
||||||
|
* @param since only events with id > since
|
||||||
|
*/
|
||||||
|
tail(opts: { subject?: string; userId?: string; limit?: number; since?: number } = {}): StoredEvent[] {
|
||||||
|
const { subject, userId, limit = 100, since = 0 } = opts;
|
||||||
|
let results = this.ring.filter((e) => {
|
||||||
|
if (e.id <= since) return false;
|
||||||
|
if (subject && !e.subject.startsWith(subject)) return false;
|
||||||
|
if (userId) {
|
||||||
|
const p = e.payload as Record<string, unknown>;
|
||||||
|
if (p.userId !== userId) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (results.length > limit) results = results.slice(results.length - limit);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bus = new Bus();
|
export const bus = new Bus();
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ import { integrationsRouter } from './routes/integrations.js';
|
|||||||
import { recommenderRouter } from './routes/recommender.js';
|
import { recommenderRouter } from './routes/recommender.js';
|
||||||
import { userRouter } from './routes/user.js';
|
import { userRouter } from './routes/user.js';
|
||||||
import { pushRouter } from './routes/push.js';
|
import { pushRouter } from './routes/push.js';
|
||||||
|
import { adminRouter } from './routes/admin.js';
|
||||||
import { mkdir } from 'fs/promises';
|
import { mkdir } from 'fs/promises';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
import { requireAuth } from './middleware/session.js';
|
||||||
|
import { requireAdmin } from './middleware/admin.js';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
await mkdir(dirname(config.DATABASE_PATH), { recursive: true });
|
await mkdir(dirname(config.DATABASE_PATH), { recursive: true });
|
||||||
runMigrations();
|
runMigrations();
|
||||||
@@ -35,6 +39,27 @@ app.use('/api/integrations', integrationsRouter);
|
|||||||
app.use('/api', recommenderRouter);
|
app.use('/api', recommenderRouter);
|
||||||
app.use('/api/user', userRouter);
|
app.use('/api/user', userRouter);
|
||||||
app.use('/api/push', pushRouter);
|
app.use('/api/push', pushRouter);
|
||||||
|
app.use('/api/admin', adminRouter);
|
||||||
|
|
||||||
|
// Proxy ml/serving endpoints through the API (admin-only).
|
||||||
|
// Allows admin UI to call /api/ml/stats/:userId, /api/ml/features/:userId
|
||||||
|
// without needing direct access to the ml/serving port.
|
||||||
|
app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {
|
||||||
|
const mlUrl = config.ML_SERVING_URL;
|
||||||
|
const target = `${mlUrl}${req.path}`;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(target, {
|
||||||
|
method: req.method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
const data = await upstream.json();
|
||||||
|
res.status(upstream.status).json(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(502).json({ error: 'ml/serving unavailable', detail: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(config.PORT, () => {
|
app.listen(config.PORT, () => {
|
||||||
console.log(`oO API listening on http://localhost:${config.PORT}`);
|
console.log(`oO API listening on http://localhost:${config.PORT}`);
|
||||||
|
|||||||
609
services/api/src/routes/admin.ts
Normal file
609
services/api/src/routes/admin.ts
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
import { type Router as ExpressRouter, Router, Response } from 'express';
|
||||||
|
import { db, rawSqlite } from '../db/index.js';
|
||||||
|
import {
|
||||||
|
users,
|
||||||
|
integrationTokens,
|
||||||
|
tipViews,
|
||||||
|
tipFeedback,
|
||||||
|
adminActions,
|
||||||
|
tipScores,
|
||||||
|
savedQueries,
|
||||||
|
} from '../db/schema.js';
|
||||||
|
import { eq, desc, sql, gte, and, isNull, lt } from 'drizzle-orm';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
|
||||||
|
import { requireAdmin } from '../middleware/admin.js';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { bus } from '../events/bus.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { getShadowPolicies, setPolicyActive } from './recommender.js';
|
||||||
|
|
||||||
|
const router: ExpressRouter = Router();
|
||||||
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/stats
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/stats', async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const now = new Date();
|
||||||
|
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const [dauRow] = await db
|
||||||
|
.select({ count: sql<number>`count(distinct user_id)` })
|
||||||
|
.from(tipViews)
|
||||||
|
.where(gte(tipViews.servedAt, dayAgo));
|
||||||
|
|
||||||
|
const [wauRow] = await db
|
||||||
|
.select({ count: sql<number>`count(distinct user_id)` })
|
||||||
|
.from(tipViews)
|
||||||
|
.where(gte(tipViews.servedAt, weekAgo));
|
||||||
|
|
||||||
|
const [tipsRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tipViews)
|
||||||
|
.where(gte(tipViews.servedAt, weekAgo));
|
||||||
|
|
||||||
|
const reactionRows = await db
|
||||||
|
.select({ action: tipFeedback.action, count: sql<number>`count(*)` })
|
||||||
|
.from(tipFeedback)
|
||||||
|
.where(gte(tipFeedback.createdAt, weekAgo))
|
||||||
|
.groupBy(tipFeedback.action);
|
||||||
|
|
||||||
|
const reactions: Record<string, number> = {};
|
||||||
|
for (const row of reactionRows) reactions[row.action] = Number(row.count);
|
||||||
|
|
||||||
|
const [totalUsersRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users)
|
||||||
|
.where(isNull(users.deletedAt));
|
||||||
|
|
||||||
|
const [activatedRow] = await db
|
||||||
|
.select({ count: sql<number>`count(distinct user_id)` })
|
||||||
|
.from(tipViews);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
dau: Number(dauRow?.count ?? 0),
|
||||||
|
wau: Number(wauRow?.count ?? 0),
|
||||||
|
tipsServedLast7d: Number(tipsRow?.count ?? 0),
|
||||||
|
reactionsLast7d: reactions,
|
||||||
|
totalUsers: Number(totalUsersRow?.count ?? 0),
|
||||||
|
activatedUsers: Number(activatedRow?.count ?? 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/users?limit=50&offset=0
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/users', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const limit = Math.min(Number(req.query.limit ?? 50), 200);
|
||||||
|
const offset = Number(req.query.offset ?? 0);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
name: users.name,
|
||||||
|
image: users.image,
|
||||||
|
role: users.role,
|
||||||
|
consentGiven: users.consentGiven,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
deletedAt: users.deletedAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.orderBy(desc(users.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const [countRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users);
|
||||||
|
|
||||||
|
res.json({ users: rows, total: Number(countRow?.count ?? 0) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/users/:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/users/:id', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const userId = req.params.id as string;
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrations = await db
|
||||||
|
.select({ provider: integrationTokens.provider, connectedAt: integrationTokens.connectedAt })
|
||||||
|
.from(integrationTokens)
|
||||||
|
.where(eq(integrationTokens.userId, user.id));
|
||||||
|
|
||||||
|
const [tipStats] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tipViews)
|
||||||
|
.where(eq(tipViews.userId, user.id));
|
||||||
|
|
||||||
|
const recentFeedback = await db
|
||||||
|
.select()
|
||||||
|
.from(tipFeedback)
|
||||||
|
.where(eq(tipFeedback.userId, user.id))
|
||||||
|
.orderBy(desc(tipFeedback.createdAt))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
const [lastView] = await db
|
||||||
|
.select({ servedAt: tipViews.servedAt })
|
||||||
|
.from(tipViews)
|
||||||
|
.where(eq(tipViews.userId, user.id))
|
||||||
|
.orderBy(desc(tipViews.servedAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
role: user.role,
|
||||||
|
consentGiven: user.consentGiven,
|
||||||
|
consentAt: user.consentAt,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
deletedAt: user.deletedAt,
|
||||||
|
},
|
||||||
|
integrations,
|
||||||
|
tipsServed: Number(tipStats?.count ?? 0),
|
||||||
|
lastTipAt: lastView?.servedAt ?? null,
|
||||||
|
recentFeedback,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/users/:id/revoke-integration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.post(
|
||||||
|
'/users/:id/revoke-integration',
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const targetUserId = req.params.id as string;
|
||||||
|
const { provider } = req.body as { provider: string };
|
||||||
|
if (!provider) {
|
||||||
|
res.status(400).json({ error: 'provider required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [token] = await db
|
||||||
|
.select()
|
||||||
|
.from(integrationTokens)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(integrationTokens.userId, targetUserId),
|
||||||
|
eq(integrationTokens.provider, provider),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(404).json({ error: 'Integration not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'todoist') {
|
||||||
|
await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token.accessToken}` },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(integrationTokens)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(integrationTokens.userId, targetUserId),
|
||||||
|
eq(integrationTokens.provider, provider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.insert(adminActions).values({
|
||||||
|
id: nanoid(),
|
||||||
|
adminId: req.userId!,
|
||||||
|
action: 'revoke_integration',
|
||||||
|
targetType: 'integration',
|
||||||
|
targetId: token.id,
|
||||||
|
detail: JSON.stringify({ userId: targetUserId, provider }),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/users/:id/reset-bandit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.post(
|
||||||
|
'/users/:id/reset-bandit',
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const targetUserId = req.params.id as string;
|
||||||
|
const mlUrl = process.env.ML_SERVING_URL ?? 'http://localhost:8000';
|
||||||
|
let mlOk = true;
|
||||||
|
let mlError = '';
|
||||||
|
try {
|
||||||
|
const mlRes = await fetch(`${mlUrl}/reset/${targetUserId}`, { method: 'POST' });
|
||||||
|
mlOk = mlRes.ok;
|
||||||
|
if (!mlOk) mlError = mlRes.statusText;
|
||||||
|
} catch (e) {
|
||||||
|
mlOk = false;
|
||||||
|
mlError = String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mlOk) {
|
||||||
|
res.status(502).json({ error: 'ml/serving reset failed', detail: mlError });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(adminActions).values({
|
||||||
|
id: nanoid(),
|
||||||
|
adminId: req.userId!,
|
||||||
|
action: 'reset_bandit',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: targetUserId,
|
||||||
|
detail: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/audit?limit=50&offset=0
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/audit', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const limit = Math.min(Number(req.query.limit ?? 50), 200);
|
||||||
|
const offset = Number(req.query.offset ?? 0);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(adminActions)
|
||||||
|
.orderBy(desc(adminActions.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const [countRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(adminActions);
|
||||||
|
|
||||||
|
res.json({ actions: rows, total: Number(countRow?.count ?? 0) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/events?subject=signals.tip&userId=&limit=100&since=0
|
||||||
|
// Returns recent events from the in-process ring buffer.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/events', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const subject = req.query.subject as string | undefined;
|
||||||
|
const userId = req.query.userId as string | undefined;
|
||||||
|
const limit = Math.min(Number(req.query.limit ?? 100), 500);
|
||||||
|
const since = Number(req.query.since ?? 0);
|
||||||
|
|
||||||
|
const events = bus.tail({ subject, userId, limit, since });
|
||||||
|
res.json({ events, nextSince: events.length > 0 ? events[events.length - 1].id : since });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/tips?limit=50&offset=0&userId=
|
||||||
|
// Recommendation log — per-tip explainability.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/tips', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const limit = Math.min(Number(req.query.limit ?? 50), 200);
|
||||||
|
const offset = Number(req.query.offset ?? 0);
|
||||||
|
const userId = req.query.userId as string | undefined;
|
||||||
|
|
||||||
|
const conditions = userId ? [eq(tipScores.userId, userId)] : [];
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(tipScores)
|
||||||
|
.where(conditions.length ? and(...(conditions as [ReturnType<typeof eq>])) : undefined)
|
||||||
|
.orderBy(desc(tipScores.servedAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const [countRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tipScores)
|
||||||
|
.where(conditions.length ? and(...(conditions as [ReturnType<typeof eq>])) : undefined);
|
||||||
|
|
||||||
|
res.json({ tips: rows, total: Number(countRow?.count ?? 0) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/reward-analytics?days=30
|
||||||
|
// Reaction distribution over time + per-policy compare.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/reward-analytics', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const days = Math.min(Number(req.query.days ?? 30), 90);
|
||||||
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// Daily reaction counts
|
||||||
|
const dailyRows = await db
|
||||||
|
.select({
|
||||||
|
date: sql<string>`date(created_at)`,
|
||||||
|
action: tipFeedback.action,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(tipFeedback)
|
||||||
|
.where(gte(tipFeedback.createdAt, since))
|
||||||
|
.groupBy(sql`date(created_at)`, tipFeedback.action)
|
||||||
|
.orderBy(sql`date(created_at)`);
|
||||||
|
|
||||||
|
// Per-policy reward distribution (tip_scores joined with tip_feedback)
|
||||||
|
const policyRows = await db
|
||||||
|
.select({
|
||||||
|
policy: tipScores.policy,
|
||||||
|
action: tipFeedback.action,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(tipScores)
|
||||||
|
.leftJoin(tipFeedback, eq(tipScores.tipId, tipFeedback.tipId))
|
||||||
|
.where(gte(tipScores.servedAt, since))
|
||||||
|
.groupBy(tipScores.policy, tipFeedback.action);
|
||||||
|
|
||||||
|
// By hour_of_day (extracted from featuresJson)
|
||||||
|
const hourRows = await db
|
||||||
|
.select({
|
||||||
|
action: tipFeedback.action,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
avgHour: sql<number>`avg(json_extract(ts.features_json, '$.hour_of_day'))`,
|
||||||
|
})
|
||||||
|
.from(tipFeedback)
|
||||||
|
.leftJoin(tipScores, eq(tipFeedback.tipId, tipScores.tipId))
|
||||||
|
.where(gte(tipFeedback.createdAt, since))
|
||||||
|
.groupBy(tipFeedback.action);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
daily: dailyRows,
|
||||||
|
byPolicy: policyRows,
|
||||||
|
byHour: hourRows,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/data-quality
|
||||||
|
// Missing-signal rates, stale token rates, feature NaN heatmap.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/data-quality', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// Total scoring calls in last 30d
|
||||||
|
const [totalRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tipScores)
|
||||||
|
.where(gte(tipScores.servedAt, thirtyDaysAgo));
|
||||||
|
const total = Number(totalRow?.count ?? 0);
|
||||||
|
|
||||||
|
// Calls with no features (features_json IS NULL)
|
||||||
|
const [missingFeaturesRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tipScores)
|
||||||
|
.where(and(gte(tipScores.servedAt, thirtyDaysAgo), isNull(tipScores.featuresJson)));
|
||||||
|
const missingFeatures = Number(missingFeaturesRow?.count ?? 0);
|
||||||
|
|
||||||
|
// Stale tokens: connected more than 7 days ago with no recent tip (proxy for stale)
|
||||||
|
const [staleTokensRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(integrationTokens)
|
||||||
|
.where(lt(integrationTokens.connectedAt, sevenDaysAgo));
|
||||||
|
const staleTokens = Number(staleTokensRow?.count ?? 0);
|
||||||
|
|
||||||
|
const [totalTokensRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(integrationTokens);
|
||||||
|
const totalTokens = Number(totalTokensRow?.count ?? 0);
|
||||||
|
|
||||||
|
// Daily feature completeness (last 14 days)
|
||||||
|
const dailyQuality = await db
|
||||||
|
.select({
|
||||||
|
date: sql<string>`date(served_at)`,
|
||||||
|
total: sql<number>`count(*)`,
|
||||||
|
withFeatures: sql<number>`sum(case when features_json is not null then 1 else 0 end)`,
|
||||||
|
avgCandidates: sql<number>`avg(candidate_count)`,
|
||||||
|
})
|
||||||
|
.from(tipScores)
|
||||||
|
.where(gte(tipScores.servedAt, new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString()))
|
||||||
|
.groupBy(sql`date(served_at)`)
|
||||||
|
.orderBy(sql`date(served_at)`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
scoringCallsLast30d: total,
|
||||||
|
missingFeatureRate: total > 0 ? missingFeatures / total : 0,
|
||||||
|
staleTokenRate: totalTokens > 0 ? staleTokens / totalTokens : 0,
|
||||||
|
totalTokens,
|
||||||
|
staleTokens,
|
||||||
|
dailyQuality,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/health
|
||||||
|
// Fan-out to all subsystem /health endpoints.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/health', async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const checks: Array<{ name: string; url: string }> = [
|
||||||
|
{ name: 'api', url: `http://localhost:${process.env.PORT ?? 3001}/health` },
|
||||||
|
{ name: 'ml-serving', url: `${config.ML_SERVING_URL}/health` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
checks.map(async ({ name, url }) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
||||||
|
return { name, status: r.ok ? 'ok' : 'degraded', latencyMs: Date.now() - t0 };
|
||||||
|
} catch {
|
||||||
|
return { name, status: 'down', latencyMs: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// SQLite health
|
||||||
|
let dbStatus = 'ok';
|
||||||
|
try {
|
||||||
|
await db.select({ one: sql<number>`1` }).from(users).limit(1);
|
||||||
|
} catch {
|
||||||
|
dbStatus = 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event bus: always ok if process is alive
|
||||||
|
const eventBusStatus = 'ok';
|
||||||
|
|
||||||
|
const services = results.map((r) =>
|
||||||
|
r.status === 'fulfilled' ? r.value : { name: 'unknown', status: 'down', latencyMs: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
services.push({ name: 'sqlite', status: dbStatus, latencyMs: 0 });
|
||||||
|
services.push({ name: 'event-bus', status: eventBusStatus, latencyMs: 0 });
|
||||||
|
|
||||||
|
const allOk = services.every((s) => s.status === 'ok');
|
||||||
|
res.json({ ok: allOk, services, checkedAt: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/policies
|
||||||
|
// POST /api/admin/policies/:name/toggle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/policies', async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
res.json({ policies: getShadowPolicies() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/policies/:name/toggle', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
const { active } = req.body as { active: boolean };
|
||||||
|
const ok = setPolicyActive(name, active);
|
||||||
|
if (!ok) {
|
||||||
|
res.status(404).json({ error: 'Policy not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(adminActions).values({
|
||||||
|
id: nanoid(),
|
||||||
|
adminId: req.userId!,
|
||||||
|
action: active ? 'enable_policy' : 'disable_policy',
|
||||||
|
targetType: 'policy',
|
||||||
|
targetId: name,
|
||||||
|
detail: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/replay-signal
|
||||||
|
// Re-emit a past event on the bus (for testing / backfill).
|
||||||
|
// Body: { subject: string, payload: object }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.post('/replay-signal', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { subject, payload } = req.body as { subject: string; payload: Record<string, unknown> };
|
||||||
|
if (!subject || !payload) {
|
||||||
|
res.status(400).json({ error: 'subject and payload required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSubjects = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
|
||||||
|
if (!validSubjects.includes(subject)) {
|
||||||
|
res.status(400).json({ error: 'unknown subject' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.publish(subject as 'signals.tip.served', payload as any);
|
||||||
|
|
||||||
|
await db.insert(adminActions).values({
|
||||||
|
id: nanoid(),
|
||||||
|
adminId: req.userId!,
|
||||||
|
action: 'replay_signal',
|
||||||
|
targetType: 'event',
|
||||||
|
targetId: null,
|
||||||
|
detail: JSON.stringify({ subject, payload }),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/sql
|
||||||
|
// Read-only SQL runner. Only SELECT allowed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.post('/sql', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { query } = req.body as { query: string };
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
res.status(400).json({ error: 'query required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = query.trim().toUpperCase();
|
||||||
|
if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) {
|
||||||
|
res.status(400).json({ error: 'Only SELECT queries are allowed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block any mutation keywords (belt-and-suspenders)
|
||||||
|
const forbidden = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|PRAGMA|VACUUM)\b/i;
|
||||||
|
if (forbidden.test(query)) {
|
||||||
|
res.status(400).json({ error: 'Mutation statements are not allowed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stmt = rawSqlite.prepare(query);
|
||||||
|
const rows = stmt.all();
|
||||||
|
res.json({ rows, rowCount: rows.length });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ error: e.message ?? 'Query error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/saved-queries
|
||||||
|
// POST /api/admin/saved-queries
|
||||||
|
// DELETE /api/admin/saved-queries/:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
router.get('/saved-queries', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(savedQueries)
|
||||||
|
.where(eq(savedQueries.adminId, req.userId!))
|
||||||
|
.orderBy(desc(savedQueries.createdAt));
|
||||||
|
res.json({ queries: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/saved-queries', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { name, querySql } = req.body as { name: string; querySql: string };
|
||||||
|
if (!name || !querySql) {
|
||||||
|
res.status(400).json({ error: 'name and querySql required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = nanoid();
|
||||||
|
await db.insert(savedQueries).values({
|
||||||
|
id,
|
||||||
|
adminId: req.userId!,
|
||||||
|
name,
|
||||||
|
sql: querySql,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
res.json({ id });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/saved-queries/:id', async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
await db
|
||||||
|
.delete(savedQueries)
|
||||||
|
.where(and(eq(savedQueries.id, id), eq(savedQueries.adminId, req.userId!)));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { router as adminRouter };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type Router as ExpressRouter, Router, Response } from 'express';
|
import { type Router as ExpressRouter, Router, Response } from 'express';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { integrationTokens, tipFeedback, tipViews } from '../db/schema.js';
|
import { integrationTokens, tipFeedback, tipViews, tipScores } from '../db/schema.js';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
|
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
@@ -24,7 +24,31 @@ interface CachedTask extends Tip {
|
|||||||
|
|
||||||
const taskCache = new Map<string, { tasks: CachedTask[]; fetchedAt: number }>();
|
const taskCache = new Map<string, { tasks: CachedTask[]; fetchedAt: number }>();
|
||||||
|
|
||||||
/** Parse a Todoist due date string into age in days (relative to now) */
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shadow-policy registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A shadow policy runs alongside the active policy, logs its picks, but does
|
||||||
|
// NOT affect what the user sees. Promotion to A/B or live is a manual step.
|
||||||
|
// Structure: Map<policyName, { active: boolean }>
|
||||||
|
const shadowPolicies = new Map<string, { active: boolean }>([
|
||||||
|
// Example: enable random as a shadow baseline
|
||||||
|
// ('random-shadow', { active: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getShadowPolicies() {
|
||||||
|
return Array.from(shadowPolicies.entries()).map(([name, s]) => ({ name, ...s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPolicyActive(name: string, active: boolean): boolean {
|
||||||
|
if (!shadowPolicies.has(name)) return false;
|
||||||
|
shadowPolicies.set(name, { active });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Todoist helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number {
|
function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number {
|
||||||
if (!due) return 0;
|
if (!due) return 0;
|
||||||
const dateStr = due.datetime ?? due.date;
|
const dateStr = due.datetime ?? due.date;
|
||||||
@@ -71,11 +95,17 @@ async function fetchTodoistTasks(userId: string, accessToken: string): Promise<C
|
|||||||
});
|
});
|
||||||
|
|
||||||
taskCache.set(userId, { tasks, fetchedAt: Date.now() });
|
taskCache.set(userId, { tasks, fetchedAt: Date.now() });
|
||||||
|
|
||||||
|
bus.publish('signals.task.synced', { userId, count: tasks.length, syncedAt: now.toISOString() });
|
||||||
|
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call ml/serving for scored selection; returns tip_id or null on failure */
|
/** Call ml/serving for scored selection; returns { tip_id, score } or null on failure */
|
||||||
async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string | null> {
|
async function remotePolicy(
|
||||||
|
userId: string,
|
||||||
|
tasks: CachedTask[],
|
||||||
|
): Promise<{ tipId: string; score: number } | null> {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
const dayOfWeek = new Date().getDay();
|
const dayOfWeek = new Date().getDay();
|
||||||
|
|
||||||
@@ -99,8 +129,8 @@ async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string
|
|||||||
signal: AbortSignal.timeout(3000),
|
signal: AbortSignal.timeout(3000),
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const { tip_id } = (await res.json()) as { tip_id: string };
|
const data = (await res.json()) as { tip_id: string; score: number };
|
||||||
return tip_id;
|
return { tipId: data.tip_id, score: data.score };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -111,7 +141,9 @@ function randomPolicy(candidates: CachedTask[]): CachedTask | null {
|
|||||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/recommend */
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/recommend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const [token] = await db
|
const [token] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -130,10 +162,15 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const dayOfWeek = new Date().getDay();
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
// RemotePolicy with RandomPolicy fallback
|
// RemotePolicy with RandomPolicy fallback
|
||||||
const scoredId = await remotePolicy(req.userId!, tasks);
|
const scored = await remotePolicy(req.userId!, tasks);
|
||||||
const tip = scoredId
|
const latencyMs = Date.now() - t0;
|
||||||
? (tasks.find((t) => t.id === scoredId) ?? randomPolicy(tasks))
|
const tip = scored
|
||||||
|
? (tasks.find((t) => t.id === scored.tipId) ?? randomPolicy(tasks))
|
||||||
: randomPolicy(tasks);
|
: randomPolicy(tasks);
|
||||||
|
|
||||||
if (!tip) {
|
if (!tip) {
|
||||||
@@ -141,25 +178,63 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const policy = scored ? 'linucb-v1' : 'random';
|
||||||
const servedAt = new Date().toISOString();
|
const servedAt = new Date().toISOString();
|
||||||
|
|
||||||
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
|
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
|
||||||
|
|
||||||
|
// Log recommendation explainability
|
||||||
|
await db.insert(tipScores).values({
|
||||||
|
id: nanoid(),
|
||||||
|
userId: req.userId!,
|
||||||
|
tipId: tip.id,
|
||||||
|
policy,
|
||||||
|
mlScore: scored ? Math.round(scored.score * 1000) : null,
|
||||||
|
featuresJson: JSON.stringify({
|
||||||
|
is_overdue: tip.features.is_overdue,
|
||||||
|
task_age_days: tip.features.task_age_days,
|
||||||
|
priority: tip.features.priority,
|
||||||
|
hour_of_day: hour,
|
||||||
|
day_of_week: dayOfWeek,
|
||||||
|
}),
|
||||||
|
candidateCount: tasks.length,
|
||||||
|
latencyMs,
|
||||||
|
servedAt,
|
||||||
|
});
|
||||||
|
|
||||||
bus.publish('signals.tip.served', {
|
bus.publish('signals.tip.served', {
|
||||||
userId: req.userId!,
|
userId: req.userId!,
|
||||||
tipId: tip.id,
|
tipId: tip.id,
|
||||||
policy: scoredId ? 'linucb-v1' : 'random',
|
policy,
|
||||||
servedAt,
|
servedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Run shadow policies (fire-and-forget, no effect on user)
|
||||||
|
for (const [name, s] of shadowPolicies) {
|
||||||
|
if (!s.active) continue;
|
||||||
|
if (name.startsWith('random')) {
|
||||||
|
const shadowTip = randomPolicy(tasks);
|
||||||
|
bus.publish('signals.tip.served', {
|
||||||
|
userId: req.userId!,
|
||||||
|
tipId: shadowTip?.id ?? 'none',
|
||||||
|
policy: `shadow:${name}`,
|
||||||
|
servedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ tip });
|
res.json({ tip });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** POST /api/tip/:id/feedback */
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/tip/:id/feedback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const { action } = req.body as { action: string };
|
const { action } = req.body as { action: string };
|
||||||
const tipId = String(req.params.id);
|
const tipId = String(req.params.id);
|
||||||
|
|
||||||
if (!['done', 'dismiss', 'snooze'].includes(action)) {
|
const validActions = ['done', 'dismiss', 'snooze', 'helpful', 'not_helpful'];
|
||||||
|
if (!validActions.includes(action)) {
|
||||||
res.status(400).json({ error: 'Invalid action' });
|
res.status(400).json({ error: 'Invalid action' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,18 +248,31 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest,
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Capture task features before clearing cache
|
// Map action to reward (helpful/not_helpful supplement behavioural signals)
|
||||||
const reward = action === 'done' ? 1.0 : action === 'dismiss' ? -1.0 : 0.0;
|
const rewardMap: Record<string, number> = {
|
||||||
|
done: 1.0,
|
||||||
|
helpful: 0.5,
|
||||||
|
snooze: 0.0,
|
||||||
|
not_helpful: -0.5,
|
||||||
|
dismiss: -1.0,
|
||||||
|
};
|
||||||
|
const reward = rewardMap[action] ?? 0.0;
|
||||||
|
|
||||||
const task = taskCache.get(req.userId!)?.tasks.find((t) => t.id === tipId);
|
const task = taskCache.get(req.userId!)?.tasks.find((t) => t.id === tipId);
|
||||||
|
|
||||||
|
// Clear cache on behavioural actions (not on explicit helpful/not_helpful)
|
||||||
|
if (['done', 'dismiss', 'snooze'].includes(action)) {
|
||||||
taskCache.delete(req.userId!);
|
taskCache.delete(req.userId!);
|
||||||
|
}
|
||||||
|
|
||||||
bus.publish('signals.tip.feedback', {
|
bus.publish('signals.tip.feedback', {
|
||||||
userId: req.userId!,
|
userId: req.userId!,
|
||||||
tipId,
|
tipId,
|
||||||
action: action as 'done' | 'dismiss' | 'snooze',
|
action: action as 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful',
|
||||||
reward,
|
reward,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (task) {
|
if (task) {
|
||||||
fetch(`${config.ML_SERVING_URL}/reward`, {
|
fetch(`${config.ML_SERVING_URL}/reward`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user