Skip to content

Commit 24f3aff

Browse files
ersinkocclaude
andcommitted
✨ feat(analytics): add model cost chart + stats endpoint tests
- Add "Cost by Model" horizontal bar chart (4th column in Row 3) - costsApi.getBreakdown type now includes byModel + totalCost fields - Remove duplicate ProviderBreakdownItem/DailyUsageItem types, reuse shared ProviderBreakdown/DailyUsage from api/types - Add 2 route tests for GET /claws/stats (aggregate + empty) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b47d0f commit 24f3aff

3 files changed

Lines changed: 95 additions & 22 deletions

File tree

packages/gateway/src/routes/claws.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,61 @@ describe('Claws Routes', () => {
8484
app = createApp();
8585
});
8686

87+
// ---- Stats ----
88+
89+
describe('GET /claws/stats', () => {
90+
it('should return aggregate statistics', async () => {
91+
service.listClaws.mockResolvedValue([
92+
{ id: 'c1', mode: 'continuous' },
93+
{ id: 'c2', mode: 'interval' },
94+
{ id: 'c3', mode: 'continuous' },
95+
]);
96+
service.listSessions.mockReturnValue([
97+
{
98+
config: { id: 'c1' },
99+
state: 'running',
100+
totalCostUsd: 0.05,
101+
cyclesCompleted: 10,
102+
totalToolCalls: 42,
103+
},
104+
{
105+
config: { id: 'c3' },
106+
state: 'paused',
107+
totalCostUsd: 0.02,
108+
cyclesCompleted: 3,
109+
totalToolCalls: 8,
110+
},
111+
]);
112+
113+
const res = await app.request('/claws/stats');
114+
expect(res.status).toBe(200);
115+
116+
const body = await res.json();
117+
expect(body.data.total).toBe(3);
118+
expect(body.data.running).toBe(1);
119+
expect(body.data.totalCycles).toBe(13);
120+
expect(body.data.totalToolCalls).toBe(50);
121+
expect(body.data.totalCost).toBeCloseTo(0.07);
122+
expect(body.data.byMode).toEqual({ continuous: 2, interval: 1 });
123+
expect(body.data.byState.running).toBe(1);
124+
expect(body.data.byState.paused).toBe(1);
125+
expect(body.data.byState.stopped).toBe(1);
126+
});
127+
128+
it('should return empty stats when no claws', async () => {
129+
service.listClaws.mockResolvedValue([]);
130+
service.listSessions.mockReturnValue([]);
131+
132+
const res = await app.request('/claws/stats');
133+
expect(res.status).toBe(200);
134+
135+
const body = await res.json();
136+
expect(body.data.total).toBe(0);
137+
expect(body.data.running).toBe(0);
138+
expect(body.data.totalCost).toBe(0);
139+
});
140+
});
141+
87142
// ---- List ----
88143

89144
describe('GET /claws', () => {

packages/ui/src/api/endpoints/summary.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ export const costsApi = {
1717
params: { period },
1818
}),
1919
getBreakdown: (period: string) =>
20-
apiClient.get<{ byProvider: ProviderBreakdown[]; daily: DailyUsage[] }>('/costs/breakdown', {
20+
apiClient.get<{
21+
byProvider: ProviderBreakdown[];
22+
byModel: ProviderBreakdown[];
23+
daily: DailyUsage[];
24+
totalCost: number;
25+
}>('/costs/breakdown', {
2126
params: { period },
2227
}),
2328
setBudget: (budget: { dailyLimit?: number; weeklyLimit?: number; monthlyLimit?: number }) =>

packages/ui/src/pages/AnalyticsPage.tsx

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,30 +53,14 @@ import {
5353
fleetApi,
5454
workflowsApi,
5555
} from '../api';
56+
import type { ProviderBreakdown, DailyUsage } from '../api';
5657
import type { SummaryData, CostsData } from '../types';
5758
import { Skeleton } from '../components/Skeleton';
5859

5960
// ---------------------------------------------------------------------------
6061
// Types
6162
// ---------------------------------------------------------------------------
6263

63-
interface ProviderBreakdownItem {
64-
provider: string;
65-
requests: number;
66-
cost: number;
67-
percentOfTotal: number;
68-
inputTokens: number;
69-
outputTokens: number;
70-
}
71-
72-
interface DailyUsageItem {
73-
date: string;
74-
requests: number;
75-
cost: number;
76-
inputTokens: number;
77-
outputTokens: number;
78-
}
79-
8064
interface ClawStats {
8165
total: number;
8266
running: number;
@@ -376,8 +360,10 @@ export function AnalyticsPage() {
376360
// Data
377361
const [usage, setUsage] = useState<CostsData | null>(null);
378362
const [breakdown, setBreakdown] = useState<{
379-
byProvider: ProviderBreakdownItem[];
380-
daily: DailyUsageItem[];
363+
byProvider: ProviderBreakdown[];
364+
byModel: ProviderBreakdown[];
365+
daily: DailyUsage[];
366+
totalCost: number;
381367
} | null>(null);
382368
const [summary, setSummary] = useState<SummaryData | null>(null);
383369
const [clawStats, setClawStats] = useState<ClawStats | null>(null);
@@ -469,6 +455,17 @@ export function AnalyticsPage() {
469455
output: p.outputTokens,
470456
}));
471457

458+
const modelCostData = (
459+
(breakdown?.byModel ?? []) as Array<ProviderBreakdown & { model?: string }>
460+
)
461+
.filter((m) => m.cost > 0)
462+
.slice(0, 6)
463+
.map((m) => ({
464+
name: m.model ?? m.provider,
465+
cost: Math.round(m.cost * 10000) / 10000,
466+
requests: m.requests,
467+
}));
468+
472469
const clawModeData = clawStats
473470
? Object.entries(clawStats.byMode)
474471
.filter(([, v]) => v > 0)
@@ -690,8 +687,8 @@ export function AnalyticsPage() {
690687
</SectionCard>
691688
</div>
692689

693-
{/* ---- Row 3: Provider Breakdown + Agent Distribution + Requests ---- */}
694-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
690+
{/* ---- Row 3: Provider Breakdown + Model Cost + Agent Distribution + Requests ---- */}
691+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
695692
<SectionCard title="Cost by Provider" icon={DollarSign} iconColor="text-pink-500">
696693
{providerDonut.length > 0 ? (
697694
<div className="flex items-center gap-4">
@@ -705,6 +702,22 @@ export function AnalyticsPage() {
705702
)}
706703
</SectionCard>
707704

705+
<SectionCard title="Cost by Model" icon={BarChart3} iconColor="text-violet-500">
706+
{modelCostData.length > 0 ? (
707+
<ResponsiveContainer width="100%" height={170}>
708+
<BarChart data={modelCostData} layout="vertical" margin={{ left: 0 }}>
709+
<CartesianGrid {...gridProps} horizontal={false} />
710+
<XAxis type="number" {...axisProps} tickFormatter={(v) => `$${v}`} />
711+
<YAxis dataKey="name" type="category" {...axisProps} width={80} />
712+
<Tooltip content={<ChartTooltip />} />
713+
<Bar dataKey="cost" name="Cost" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
714+
</BarChart>
715+
</ResponsiveContainer>
716+
) : (
717+
<EmptyChart height={170} message="No model data" />
718+
)}
719+
</SectionCard>
720+
708721
<SectionCard title="Agent Distribution" icon={Bot} iconColor="text-emerald-500">
709722
<ResponsiveContainer width="100%" height={170}>
710723
<BarChart data={agentBarData} layout="vertical" margin={{ left: 0 }}>

0 commit comments

Comments
 (0)