Add attention inbox and AI usage observability
This commit is contained in:
1 parent
ca59ba0f1f
commit
83b5f1f5b6
30 files changed
+702
-28
No files matched your search
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user