feat(admin): per-user profile view + rebuild action (#81 phase B.1)
Surfaces phase A's profile features in /admin/users/:id so we can verify they're actually computing useful values before investing in bandit consumption. The detail GET now includes profile rows joined with registry metadata (name, value, age, fresh badge, ttlSec, description). Read does NOT trigger compute — staleness must be visible. A new POST .../profile/rebuild button force-recomputes and is audit-logged like reset-bandit. Refs #81. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUserDetail, revokeIntegration, resetBandit, type AdminUserDetail } from '@/lib/api';
|
||||
import {
|
||||
getUserDetail,
|
||||
revokeIntegration,
|
||||
resetBandit,
|
||||
rebuildUserProfile,
|
||||
type AdminUserDetail,
|
||||
type ProfileFeatureView,
|
||||
} from '@/lib/api';
|
||||
|
||||
export function UserDetail({ userId }: { userId: string }) {
|
||||
const [data, setData] = useState<AdminUserDetail | null>(null);
|
||||
@@ -44,10 +51,22 @@ export function UserDetail({ userId }: { userId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRebuildProfile() {
|
||||
setBusy('profile');
|
||||
try {
|
||||
const { profile } = await rebuildUserProfile(userId);
|
||||
setData((d) => (d ? { ...d, profile } : d));
|
||||
} 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;
|
||||
const { user, integrations, tipsServed, lastTipAt, recentFeedback, profile } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
@@ -102,6 +121,22 @@ export function UserDetail({ userId }: { userId: string }) {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Profile features (#81 phase B) */}
|
||||
<Section
|
||||
title="Profile features"
|
||||
action={
|
||||
<button
|
||||
onClick={handleRebuildProfile}
|
||||
disabled={busy === 'profile'}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{busy === 'profile' ? 'Rebuilding…' : 'Rebuild'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ProfileTable rows={profile} />
|
||||
</Section>
|
||||
|
||||
{/* Tip stats */}
|
||||
<Section title="Tip activity">
|
||||
<Row label="Tips served (all time)" value={String(tipsServed)} />
|
||||
@@ -140,15 +175,52 @@ export function UserDetail({ userId }: { userId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: 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>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium">{title}</p>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileTable({ rows }: { rows: ProfileFeatureView[] }) {
|
||||
if (rows.length === 0) return <p className="text-sm text-gray-500">No profile features registered.</p>;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{rows.map((r) => (
|
||||
<div key={r.name} className="flex items-baseline gap-3 text-sm">
|
||||
<span className="w-44 flex-shrink-0 text-gray-500 font-mono text-xs" title={r.description}>
|
||||
{r.name}
|
||||
</span>
|
||||
<span className="text-gray-200 tabular-nums w-24">{formatValue(r)}</span>
|
||||
<span className="text-xs text-gray-500 tabular-nums">{formatAge(r)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(r: ProfileFeatureView): string {
|
||||
if (r.value == null) return '—';
|
||||
if (r.dtype === 'numeric') {
|
||||
const n = Number(r.value);
|
||||
return Math.abs(n) < 10 ? n.toFixed(3) : n.toFixed(0);
|
||||
}
|
||||
return String(r.value);
|
||||
}
|
||||
|
||||
function formatAge(r: ProfileFeatureView): string {
|
||||
if (r.ageSec == null) return 'never computed';
|
||||
const mins = r.ageSec / 60;
|
||||
const ageLabel = mins < 60 ? `${mins.toFixed(0)}m` : mins < 1440 ? `${(mins / 60).toFixed(1)}h` : `${(mins / 1440).toFixed(1)}d`;
|
||||
const tag = r.fresh ? 'fresh' : 'stale';
|
||||
return `${ageLabel} (${tag})`;
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-3 text-sm">
|
||||
|
||||
Reference in New Issue
Block a user