Add attention inbox and AI usage observability

This commit is contained in:
jjbrunton committed 2026-08-09 08:52:32 +01:00
1 parent ca59ba0f1f
commit 83b5f1f5b6
30 files changed
+702 -28

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ export interface NavigationItem {
export const navigationItems: readonly NavigationItem[] = [
{ label: 'Overview', path: '/', description: 'System health and current activity' },
{ label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks' },
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
+2
View File
@@ -7,6 +7,7 @@ import { OperationsPage } from '../features/operations/operations-page.js';
import { PerformancePage } from '../features/performance/performance-page.js';
import { SettingsRoute } from '../features/settings/settings-route.js';
import { RouteErrorPage } from './route-error-page.js';
import { AttentionPage } from '../features/attention/attention-page.js';
export function createDashboardRoutes(): RouteObject[] {
return [
@@ -16,6 +17,7 @@ export function createDashboardRoutes(): RouteObject[] {
errorElement: <RouteErrorPage />,
children: [
{ index: true, element: <OverviewPage /> },
{ path: 'attention', element: <AttentionPage /> },
{ path: 'conversations', element: <ConversationRoute /> },
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
{ path: 'media', element: <MediaRoute /> },
@@ -0,0 +1,16 @@
import { AttentionResponseSchema } from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
export function createAttentionApi(fetchImplementation?: typeof fetch) {
const request = createDashboardRequest(fetchImplementation);
return {
async getAttention() {
const response = await request('/api/attention');
const payload: unknown = await response.json();
if (!response.ok) throw new Error(`Attention inbox unavailable (${response.status})`);
return AttentionResponseSchema.parse(payload);
},
};
}
export const attentionApi = createAttentionApi();
@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import type { AttentionItem } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { attentionApi } from './attention-api.js';
import './attention.css';
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
function itemAction(item: AttentionItem) {
if (!item.conversationId) return null;
return <Link className="button secondary" to={`/conversations/${encodeURIComponent(item.conversationId)}`}>Open conversation</Link>;
}
export function AttentionPage() {
const query = useQuery({ queryKey: ['attention'], queryFn: attentionApi.getAttention, refetchInterval: 30_000 });
if (query.isPending) return <div className="page-state" role="status">Loading attention inbox…</div>;
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Attention inbox unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
const critical = query.data.items.filter((item) => item.severity === 'critical').length;
return (
<div className="page attention-page">
<header className="page-header"><div><span className="eyebrow">Operator queue</span><h1>Needs attention</h1><p>Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.</p></div><button className="button secondary" onClick={() => void query.refetch()} type="button">Refresh</button></header>
<section aria-label="Attention totals" className="status-strip"><div><span className={`status-dot ${query.data.items.length === 0 ? 'running' : 'stopped'}`} aria-hidden="true" /><div><small>Open items</small><strong>{query.data.items.length}</strong></div></div><span className={critical > 0 ? 'pill warning' : 'pill success'}>{critical} critical</span><span className="status-note">Updated {formatTime(query.data.generatedAt)}</span></section>
{query.data.items.length === 0 ? <div className="empty-state panel"><strong>Nothing needs attention.</strong><span>Automation and account health have no outstanding operator items.</span></div> : (
<ol aria-label="Attention items" className="attention-list">{query.data.items.map((item) => (
<li className={`attention-item is-${item.severity}`} key={item.id}><div className="attention-item-heading"><div><span className={`pill ${item.severity === 'critical' ? 'warning' : 'neutral'}`}>{item.kind.replaceAll('-', ' ')}</span><h2>{item.title}</h2></div><time dateTime={new Date(item.occurredAt).toISOString()}>{formatTime(item.occurredAt)}</time></div><p>{item.detail}</p>{item.owner ? <AccountOwnerBadge owner={item.owner} /> : null}{itemAction(item)}</li>
))}</ol>
)}
</div>
);
}
@@ -0,0 +1,8 @@
.attention-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
.attention-item { display: grid; gap: .8rem; padding: 1rem; background: var(--surface); border: 1px solid var(--border); border-left: .3rem solid var(--warning); border-radius: .9rem; }
.attention-item.is-critical { border-left-color: var(--danger); }
.attention-item-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.attention-item-heading > div { display: grid; gap: .55rem; }
.attention-item-heading time { color: var(--muted); font-size: .8rem; text-align: right; }
.attention-item p { margin: 0; color: var(--muted); }
.attention-item .button { justify-self: start; text-decoration: none; }
@@ -4,6 +4,7 @@ import {
SubredditPerformanceResponseSchema,
SuccessfulExamplesResponseSchema,
FunnelResponseSchema,
AiUsageSummaryResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import type { z } from 'zod';
import { createDashboardRequest } from '../../api/request.js';
@@ -13,6 +14,7 @@ type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSch
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
type FunnelResponse = z.infer<typeof FunnelResponseSchema>;
type AiUsageSummaryResponse = z.infer<typeof AiUsageSummaryResponseSchema>;
async function parseResponse<T>(
response: Response,
@@ -38,6 +40,7 @@ export interface PerformanceApi {
getDailyTrend(days: number): Promise<DailyTrendResponse>;
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
getFunnel(): Promise<FunnelResponse>;
getAiUsage(days: number): Promise<AiUsageSummaryResponse>;
}
export function createPerformanceApi(
@@ -75,6 +78,12 @@ export function createPerformanceApi(
FunnelResponseSchema,
);
},
async getAiUsage(days: number) {
return parseResponse(
await request(`/api/analytics/ai-usage?days=${days}`),
AiUsageSummaryResponseSchema,
);
},
} satisfies PerformanceApi;
}
@@ -5,6 +5,7 @@ import type {
SubredditPerformanceResponseSchema,
SuccessfulExample,
FunnelResponseSchema,
AiUsageSummaryResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import {
useDailyTrendQuery,
@@ -12,6 +13,7 @@ import {
useSubredditPerformanceQuery,
useSuccessfulExamplesQuery,
useFunnelQuery,
useAiUsageQuery,
} from './performance-queries.js';
import './performance.css';
@@ -20,6 +22,33 @@ type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[n
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
type Funnel = z.infer<typeof FunnelResponseSchema>;
type AiUsageSummary = z.infer<typeof AiUsageSummaryResponseSchema>;
function formatUsd(value: number): string {
return new Intl.NumberFormat(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4,
}).format(value);
}
function AiUsagePanel({ usage }: { usage: AiUsageSummary }) {
const successRate = usage.requests > 0 ? Math.round((usage.successes / usage.requests) * 100) : 0;
return (
<section aria-labelledby="ai-usage-heading" className="panel feature-panel">
<div className="panel-heading"><div><span className="eyebrow">OpenRouter · last {usage.days} days</span><h2 id="ai-usage-heading">AI cost and reliability</h2></div><span className="sample-size">{usage.requests} requests</span></div>
<div aria-label="AI usage totals" className="metrics-grid performance-metrics">
<article className="metric-card"><span>Reported cost</span><strong>{formatUsd(usage.costUsd)}</strong><small>OpenRouter usage accounting</small></article>
<article className="metric-card"><span>Tokens</span><strong>{usage.totalTokens.toLocaleString()}</strong><small>{usage.promptTokens.toLocaleString()} prompt · {usage.completionTokens.toLocaleString()} completion</small></article>
<article className="metric-card"><span>Success rate</span><strong>{successRate}%</strong><small>{usage.refusals} refusals · {usage.errors} errors/empty</small></article>
<article className="metric-card"><span>Average latency</span><strong>{usage.averageLatencyMs.toLocaleString()} ms</strong><small>All recorded attempts</small></article>
</div>
{usage.models.length === 0 ? <p className="feature-empty">No AI usage has been recorded yet. New requests will appear here.</p> : (
<div className="table-scroll"><table aria-label="AI usage by model" className="performance-table"><thead><tr><th scope="col">Model</th><th scope="col">Requests</th><th scope="col">Success</th><th scope="col">Refusals</th><th scope="col">Errors</th><th scope="col">Tokens</th><th scope="col">Cost</th><th scope="col">Latency</th></tr></thead><tbody>{usage.models.map((model) => (
<tr key={model.model}><th scope="row">{model.model}</th><td>{model.requests}</td><td>{model.successes}</td><td>{model.refusals}</td><td>{model.errors}</td><td>{model.totalTokens.toLocaleString()}</td><td>{formatUsd(model.costUsd)}</td><td>{model.averageLatencyMs.toLocaleString()} ms</td></tr>
))}</tbody></table></div>
)}
</section>
);
}
function SampleSize({ totalSent }: { totalSent: number }) {
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
@@ -129,7 +158,8 @@ export function PerformancePage() {
const trend = useDailyTrendQuery(30);
const examples = useSuccessfulExamplesQuery();
const funnel = useFunnelQuery();
const queries = [subreddits, strategies, trend, examples, funnel];
const aiUsage = useAiUsageQuery(30);
const queries = [subreddits, strategies, trend, examples, funnel, aiUsage];
if (queries.some((query) => query.isPending)) {
return <div className="page-state" role="status">Loading performance…</div>;
}
@@ -142,6 +172,7 @@ export function PerformancePage() {
<div className="page performance-page">
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div></header>
{funnel.data ? <FunnelPanel funnel={funnel.data} /> : null}
{aiUsage.data ? <AiUsagePanel usage={aiUsage.data} /> : null}
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
<div className="performance-grid">
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
@@ -7,6 +7,7 @@ export const performanceQueryKeys = {
trend: (days: number) => ['performance', 'trend', days] as const,
successfulExamples: ['performance', 'successful-examples'] as const,
funnel: ['performance', 'funnel'] as const,
aiUsage: (days: number) => ['performance', 'ai-usage', days] as const,
};
export function useSubredditPerformanceQuery() {
@@ -43,3 +44,10 @@ export function useFunnelQuery() {
queryFn: performanceApi.getFunnel,
});
}
export function useAiUsageQuery(days: number) {
return useQuery({
queryKey: performanceQueryKeys.aiUsage(days),
queryFn: () => performanceApi.getAiUsage(days),
});
}