feat: AI Agents management page with per-agent performance tracking

New Agents tab in the sidebar (Ops group) for viewing, editing, and
creating AI agent configurations:

Database (migration 026):
- ai_agents table: editable configs for each LLM agent (model, prompts,
  temperature, tokens, retries). source='system' for built-in,
  source='user' for custom. Seeds 3 system agents (Document Extractor,
  Event Classifier, Thesis Rewriter) using WHERE NOT EXISTS to never
  overwrite user edits across reinstalls.
- agent_performance_log table: per-invocation metrics (duration,
  confidence, retries, tokens, errors) linked to agent config.

API endpoints:
- GET/POST /api/agents — list and create agents
- GET/PUT/DELETE /api/agents/{id} — view, edit, delete (system agents
  can be edited but not deleted)
- GET /api/agents/{id}/performance — aggregated metrics (success rate,
  avg/p95 latency, confidence, token usage)
- GET /api/agents/{id}/performance/history — hourly time series

Frontend:
- AgentsPage with sidebar list + detail panel
- Agent detail: config display, system prompt viewer, performance
  dashboard with metrics cards and time-series chart
- Edit form: all config fields editable including system prompt,
  model, temperature, tokens, retries
- Create form: new user-defined agents with auto-slug generation
- System agents show blue badge, user agents show green badge
This commit is contained in:
Celes Renata
2026-04-17 01:24:35 +00:00
parent 86b549e5e1
commit 45752b9a29
5 changed files with 742 additions and 0 deletions
+2
View File
@@ -18,6 +18,7 @@ import {
List,
Globe,
BarChart3,
Bot,
} from 'lucide-react';
interface NavItem {
@@ -42,6 +43,7 @@ const navItems: NavItem[] = [
{ to: '/ops/pipeline', label: 'Pipeline', icon: <Activity size={18} />, group: 'Ops' },
{ to: '/ops/ingestion', label: 'Ingestion', icon: <Download size={18} />, group: 'Ops' },
{ to: '/ops/model', label: 'Model Perf', icon: <Cpu size={18} />, group: 'Ops' },
{ to: '/agents', label: 'Agents', icon: <Bot size={18} />, group: 'Ops' },
{ to: '/ops/coverage', label: 'Coverage', icon: <Radar size={18} />, group: 'Ops' },
{ to: '/analytics/query', label: 'SQL Explorer', icon: <Terminal size={18} />, group: 'Analytics' },
{ to: '/analytics/dashboards', label: 'Dashboards', icon: <LayoutDashboard size={18} />, group: 'Analytics' },
+459
View File
@@ -0,0 +1,459 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut, apiDelete } from '../api/client';
import { Card, LoadingSpinner, StatusBadge } from '../components/ui';
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Agent {
id: string;
name: string;
slug: string;
purpose: string;
model_provider: string;
model_name: string;
system_prompt: string;
user_prompt_template: string;
prompt_version: string;
schema_version: string;
temperature: number;
max_tokens: number;
timeout_seconds: number;
max_retries: number;
active: boolean;
source: string;
created_at: string;
updated_at: string;
}
interface AgentPerformance {
total_invocations: number;
successes: number;
failures: number;
avg_duration_ms: number | null;
p95_duration_ms: number | null;
avg_confidence: number | null;
avg_retries: number | null;
total_input_tokens: number | null;
total_output_tokens: number | null;
success_rate: number | null;
}
interface PerfHistoryPoint {
hour: string;
invocations: number;
successes: number;
avg_duration_ms: number;
avg_confidence: number;
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
function useAgents() {
return useQuery<Agent[]>({
queryKey: ['agents'],
queryFn: () => apiGet<Agent[]>('query', '/api/agents'),
});
}
function useAgentPerformance(agentId: string | undefined, hours = 24) {
return useQuery<AgentPerformance>({
queryKey: ['agent-performance', agentId, hours],
queryFn: () => apiGet<AgentPerformance>('query', `/api/agents/${agentId}/performance?hours=${hours}`),
enabled: !!agentId,
});
}
function useAgentPerfHistory(agentId: string | undefined, hours = 24) {
return useQuery<PerfHistoryPoint[]>({
queryKey: ['agent-perf-history', agentId, hours],
queryFn: () => apiGet<PerfHistoryPoint[]>('query', `/api/agents/${agentId}/performance/history?hours=${hours}`),
enabled: !!agentId,
});
}
// ---------------------------------------------------------------------------
// Page Component
// ---------------------------------------------------------------------------
export function AgentsPage() {
const qc = useQueryClient();
const { data: agents, isLoading } = useAgents();
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
const [editing, setEditing] = useState(false);
const [creating, setCreating] = useState(false);
const selected = agents?.find((a) => a.id === selectedId);
if (isLoading) return <LoadingSpinner />;
return (
<div className="flex gap-4 h-[calc(100vh-3rem)]">
{/* Agent List Sidebar */}
<div className="w-64 shrink-0 overflow-y-auto rounded-lg border border-surface-700 bg-surface-900 p-3 space-y-1">
<div className="flex items-center justify-between mb-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-500">Agents</h2>
<button
onClick={() => { setCreating(true); setSelectedId(undefined); setEditing(false); }}
className="rounded bg-brand-600 px-2 py-0.5 text-[10px] font-medium text-white hover:bg-brand-700"
>
+ New
</button>
</div>
{(agents ?? []).map((agent) => (
<button
key={agent.id}
onClick={() => { setSelectedId(agent.id); setEditing(false); setCreating(false); }}
className={`w-full rounded-md px-3 py-2 text-left text-sm transition-colors ${
selectedId === agent.id
? 'bg-brand-600/20 border border-brand-500/50 text-brand-300'
: 'text-gray-300 hover:bg-surface-800'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium truncate">{agent.name}</span>
{!agent.active && <span className="text-[9px] text-gray-600">OFF</span>}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-gray-500">{agent.model_name}</span>
<span className={`rounded px-1 py-0 text-[9px] ${agent.source === 'system' ? 'bg-blue-900/30 text-blue-400' : 'bg-emerald-900/30 text-emerald-400'}`}>
{agent.source}
</span>
</div>
</button>
))}
</div>
{/* Detail Panel */}
<div className="flex-1 overflow-y-auto space-y-4">
{creating ? (
<CreateAgentForm
onCreated={(id) => { qc.invalidateQueries({ queryKey: ['agents'] }); setSelectedId(id); setCreating(false); }}
onCancel={() => setCreating(false)}
/>
) : selected ? (
editing ? (
<EditAgentForm agent={selected} onSaved={() => { qc.invalidateQueries({ queryKey: ['agents'] }); setEditing(false); }} onCancel={() => setEditing(false)} />
) : (
<AgentDetail agent={selected} onEdit={() => setEditing(true)} onDeleted={() => { qc.invalidateQueries({ queryKey: ['agents'] }); setSelectedId(undefined); }} />
)
) : (
<Card>
<p className="text-sm text-gray-500">Select an agent to view its configuration and performance, or create a new one.</p>
</Card>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Agent Detail View
// ---------------------------------------------------------------------------
function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () => void; onDeleted: () => void }) {
const qc = useQueryClient();
const { data: perf } = useAgentPerformance(agent.id);
const { data: history } = useAgentPerfHistory(agent.id);
const deleteMut = useMutation({
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agent.id}`),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agents'] }); onDeleted(); },
});
const chartData = (history ?? []).map((pt) => ({
hour: new Date(pt.hour).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
invocations: pt.invocations,
successRate: pt.invocations > 0 ? Math.round((pt.successes / pt.invocations) * 100) : 0,
avgLatency: pt.avg_duration_ms,
avgConfidence: Math.round((pt.avg_confidence ?? 0) * 100),
}));
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-100">{agent.name}</h1>
<StatusBadge status={agent.active ? 'active' : 'disabled'} />
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${agent.source === 'system' ? 'bg-blue-900/30 text-blue-400 border border-blue-700/50' : 'bg-emerald-900/30 text-emerald-400 border border-emerald-700/50'}`}>
{agent.source}
</span>
</div>
<div className="flex gap-2">
<button onClick={onEdit} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">Edit</button>
{agent.source === 'user' && (
<button onClick={() => deleteMut.mutate()} className="rounded-md border border-red-700/50 px-3 py-1.5 text-sm text-red-400 hover:bg-red-900/20">Delete</button>
)}
</div>
</div>
{/* Config */}
<Card>
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
<div><dt className="text-gray-500">Model</dt><dd className="font-mono text-gray-200">{agent.model_provider}/{agent.model_name}</dd></div>
<div><dt className="text-gray-500">Prompt Version</dt><dd className="text-gray-200">{agent.prompt_version || '—'}</dd></div>
<div><dt className="text-gray-500">Schema Version</dt><dd className="text-gray-200">{agent.schema_version}</dd></div>
<div><dt className="text-gray-500">Temperature</dt><dd className="text-gray-200">{agent.temperature}</dd></div>
<div><dt className="text-gray-500">Max Tokens</dt><dd className="text-gray-200">{agent.max_tokens.toLocaleString()}</dd></div>
<div><dt className="text-gray-500">Timeout</dt><dd className="text-gray-200">{agent.timeout_seconds}s</dd></div>
<div><dt className="text-gray-500">Max Retries</dt><dd className="text-gray-200">{agent.max_retries}</dd></div>
<div><dt className="text-gray-500">Updated</dt><dd className="text-gray-300">{new Date(agent.updated_at).toLocaleString()}</dd></div>
</dl>
</Card>
{agent.purpose && (
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Purpose</h2>
<p className="text-sm text-gray-300">{agent.purpose}</p>
</Card>
)}
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">System Prompt</h2>
<pre className="whitespace-pre-wrap rounded-md bg-surface-950 p-3 text-xs text-gray-300 max-h-40 overflow-y-auto">{agent.system_prompt || '(none)'}</pre>
</Card>
{/* Performance Metrics */}
{perf && (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Performance (24h)</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<MetricCard label="Invocations" value={String(perf.total_invocations ?? 0)} />
<MetricCard label="Success Rate" value={perf.success_rate != null ? `${(perf.success_rate * 100).toFixed(1)}%` : '—'} color={perf.success_rate != null && perf.success_rate >= 0.95 ? 'text-green-400' : 'text-yellow-400'} />
<MetricCard label="Avg Latency" value={perf.avg_duration_ms != null ? `${Math.round(perf.avg_duration_ms)}ms` : '—'} />
<MetricCard label="P95 Latency" value={perf.p95_duration_ms != null ? `${Math.round(perf.p95_duration_ms)}ms` : '—'} />
<MetricCard label="Avg Confidence" value={perf.avg_confidence != null ? `${(perf.avg_confidence * 100).toFixed(0)}%` : '—'} />
</div>
<div className="mt-2 flex gap-4 text-xs text-gray-500">
<span>Retries avg: {perf.avg_retries?.toFixed(1) ?? '—'}</span>
<span>Input tokens: {perf.total_input_tokens?.toLocaleString() ?? '—'}</span>
<span>Output tokens: {perf.total_output_tokens?.toLocaleString() ?? '—'}</span>
</div>
</Card>
)}
{/* Performance Chart */}
{chartData.length > 1 && (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Performance Over Time</h2>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis yAxisId="left" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Line yAxisId="left" type="monotone" dataKey="invocations" name="Invocations" stroke="#3b82f6" strokeWidth={2} dot={false} />
<Line yAxisId="right" type="monotone" dataKey="successRate" name="Success %" stroke="#10b981" strokeWidth={2} dot={false} />
<Line yAxisId="right" type="monotone" dataKey="avgConfidence" name="Confidence %" stroke="#f59e0b" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</Card>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Edit Form
// ---------------------------------------------------------------------------
function EditAgentForm({ agent, onSaved, onCancel }: { agent: Agent; onSaved: () => void; onCancel: () => void }) {
const [form, setForm] = useState({
name: agent.name,
purpose: agent.purpose,
model_provider: agent.model_provider,
model_name: agent.model_name,
system_prompt: agent.system_prompt,
user_prompt_template: agent.user_prompt_template,
prompt_version: agent.prompt_version,
schema_version: agent.schema_version,
temperature: agent.temperature,
max_tokens: agent.max_tokens,
timeout_seconds: agent.timeout_seconds,
max_retries: agent.max_retries,
active: agent.active,
});
const mutation = useMutation({
mutationFn: (body: Record<string, unknown>) => apiPut<Agent>('query', `/api/agents/${agent.id}`, body),
onSuccess: onSaved,
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate(form);
}
return (
<Card>
<h2 className="mb-4 text-sm font-medium text-gray-400">Edit Agent: {agent.name}</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<FormRow label="Name">
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Purpose">
<textarea value={form.purpose} onChange={(e) => setForm({ ...form, purpose: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-16" />
</FormRow>
<div className="grid grid-cols-2 gap-3">
<FormRow label="Provider">
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Model">
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<FormRow label="System Prompt">
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-32 font-mono text-xs" />
</FormRow>
<FormRow label="User Prompt Template">
<textarea value={form.user_prompt_template} onChange={(e) => setForm({ ...form, user_prompt_template: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-24 font-mono text-xs" />
</FormRow>
<div className="grid grid-cols-4 gap-3">
<FormRow label="Temperature">
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Max Tokens">
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Timeout (s)">
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Max Retries">
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
Active
</label>
</div>
<div className="flex gap-2 pt-2">
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
{mutation.isPending ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={onCancel} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
</div>
{mutation.isError && <p className="text-xs text-red-400">Failed to save</p>}
</form>
</Card>
);
}
// ---------------------------------------------------------------------------
// Create Form
// ---------------------------------------------------------------------------
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (id: string) => void; onCancel: () => void }) {
const [form, setForm] = useState({
name: '',
slug: '',
purpose: '',
model_provider: 'ollama',
model_name: 'llama3.1:8b',
system_prompt: '',
user_prompt_template: '',
temperature: 0,
max_tokens: 32768,
timeout_seconds: 120,
max_retries: 2,
});
const mutation = useMutation({
mutationFn: (body: Record<string, unknown>) => apiPost<{ id: string }>('query', '/api/agents', body),
onSuccess: (data) => onCreated(data.id),
});
// Auto-generate slug from name
function handleNameChange(name: string) {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
setForm({ ...form, name, slug });
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate(form);
}
return (
<Card>
<h2 className="mb-4 text-sm font-medium text-gray-400">Create New Agent</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<FormRow label="Name">
<input value={form.name} onChange={(e) => handleNameChange(e.target.value)} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
</FormRow>
<FormRow label="Slug">
<input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none font-mono" required />
</FormRow>
</div>
<FormRow label="Purpose">
<textarea value={form.purpose} onChange={(e) => setForm({ ...form, purpose: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-16" />
</FormRow>
<div className="grid grid-cols-2 gap-3">
<FormRow label="Provider">
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Model">
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<FormRow label="System Prompt">
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-32 font-mono text-xs" />
</FormRow>
<div className="grid grid-cols-4 gap-3">
<FormRow label="Temperature">
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Max Tokens">
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Timeout (s)">
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Max Retries">
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<div className="flex gap-2 pt-2">
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
{mutation.isPending ? 'Creating…' : 'Create'}
</button>
<button type="button" onClick={onCancel} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
</div>
{mutation.isError && <p className="text-xs text-red-400">Failed to create agent</p>}
</form>
</Card>
);
}
// ---------------------------------------------------------------------------
// Shared Components
// ---------------------------------------------------------------------------
function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-xs text-gray-500">{label}</label>
{children}
</div>
);
}
function MetricCard({ label, value, color = 'text-gray-100' }: { label: string; value: string; color?: string }) {
return (
<div className="text-center">
<div className={`text-lg font-bold ${color}`}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
);
}
+8
View File
@@ -29,6 +29,7 @@ import { DashboardsPage } from './pages/Dashboards';
import { HomePage } from './pages/Home';
import { GlobalEventsPage } from './pages/GlobalEvents';
import { GlobalEventDetailPage } from './pages/GlobalEventDetail';
import { AgentsPage } from './pages/Agents';
// Root route wraps everything in the app shell layout
const rootRoute = createRootRoute({
@@ -157,6 +158,12 @@ const globalEventDetailRoute = createRoute({
component: GlobalEventDetailPage,
});
const agentsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/agents',
component: AgentsPage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
companiesRoute,
@@ -181,6 +188,7 @@ const routeTree = rootRoute.addChildren([
analyticsDashboardsRoute,
globalEventsRoute,
globalEventDetailRoute,
agentsRoute,
]);
export const router = createRouter({ routeTree });