[w9-2] Add 90d window, time-window selector, and chart/table toggle (#528)

Backend: widen usage _PeriodType to 24h/7d/30d/90d and add a 90d branch to
_parse_period (daily buckets already cover it). TestParsePeriod pins the
contract per window.

Frontend: UsagePeriod += 90d with a scaleFor helper (replacing 6 inline
ternaries) and 90 daily mock points. One generic SegmentedControl primitive
(reuses Radix Tabs) drives both the metrics time-window selector
(24h/7d/30d/90d) and the per-chart Chart/Table view toggle — one file, two
roles. The Token Usage & Costs tab drops 8 hardcoded '24h' hooks for a
single period state + selector; the stale '(24h)' cost-card parenthetical
goes too. The Performance landing tab gains a TaskStatusChart donut fed by
the status counts already on the page (no new hook). Agent/team bar charts
gain an inline table view.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:33:50 +02:00
committed by GitHub
co-authored by Renn F
parent 533ea97d01
commit d9084eeb07
12 changed files with 456 additions and 52 deletions
+43 -11
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Suspense, useEffect } from "react"; import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useOrchestratorStatus } from "@/hooks/use-agents"; import { useOrchestratorStatus } from "@/hooks/use-agents";
import { useTasks } from "@/hooks/use-tasks"; import { useTasks } from "@/hooks/use-tasks";
@@ -17,11 +17,13 @@ import {
useSpawnWaste, useSpawnWaste,
useUsageSessions, useUsageSessions,
} from "@/hooks/use-usage"; } from "@/hooks/use-usage";
import type { UsagePeriod } from "@/lib/api/usage";
import { TaskStatus, Team } from "@/types"; import { TaskStatus, Team } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { import {
ResponsiveTable, ResponsiveTable,
@@ -36,6 +38,7 @@ import {
ModelUsageDonut, ModelUsageDonut,
AgentUsageChart, AgentUsageChart,
TeamUsageChart, TeamUsageChart,
TaskStatusChart,
SessionsTable, SessionsTable,
} from "@/components/metrics"; } from "@/components/metrics";
import { import {
@@ -327,7 +330,8 @@ function PerformanceTabContent() {
{/* Task Status */} {/* Task Status */}
<div> <div>
<h2 className="text-lg font-semibold mb-3">Task Status</h2> <h2 className="text-lg font-semibold mb-3">Task Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5"> <div className="grid gap-4 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3">
<div className="lg:col-span-2 grid gap-4 sm:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5">
<MetricCard <MetricCard
title="Pending" title="Pending"
value={pending} value={pending}
@@ -354,6 +358,16 @@ function PerformanceTabContent() {
icon={<CheckCircle className="h-4 w-4 text-green-500" />} icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/> />
</div> </div>
<TaskStatusChart
slices={[
{ name: "Pending", value: pending },
{ name: "In Progress", value: inProgress },
{ name: "Blocked", value: blocked },
{ name: "Awaiting QA", value: awaitingQa },
{ name: "Completed", value: completed },
]}
/>
</div>
</div> </div>
{/* Agent Status */} {/* Agent Status */}
@@ -408,23 +422,41 @@ function PerformanceTabContent() {
// ─── Token Usage & Costs tab content ───────────────────────────────────────── // ─── Token Usage & Costs tab content ─────────────────────────────────────────
const TIME_WINDOW_OPTIONS: { value: UsagePeriod; label: string }[] = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
{ value: "90d", label: "90d" },
];
function TokenUsageCostsSection() { function TokenUsageCostsSection() {
const { data: summary, isLoading: loadingSnap } = useUsageSummary("24h"); const [period, setPeriod] = useState<UsagePeriod>("24h");
const { data: timeSeries, isLoading: loadingTS } = useUsageTimeSeries("24h"); const { data: summary, isLoading: loadingSnap } = useUsageSummary(period);
const { data: agentUsage, isLoading: loadingAgents } = useAgentUsage("24h"); const { data: timeSeries, isLoading: loadingTS } = useUsageTimeSeries(period);
const { data: teamUsage, isLoading: loadingTeams } = useTeamUsage("24h"); const { data: agentUsage, isLoading: loadingAgents } = useAgentUsage(period);
const { data: teamUsage, isLoading: loadingTeams } = useTeamUsage(period);
const { data: sessions, isLoading: loadingSessions } = useUsageSessions(100); const { data: sessions, isLoading: loadingSessions } = useUsageSessions(100);
const { data: modelUsage, isLoading: loadingModels } = useModelUsage("24h"); const { data: modelUsage, isLoading: loadingModels } = useModelUsage(period);
const { data: projection, isLoading: loadingProj } = useUsageProjection(); const { data: projection, isLoading: loadingProj } = useUsageProjection();
const { data: cacheStats, isLoading: loadingCache } = const { data: cacheStats, isLoading: loadingCache } =
useCacheEfficiency("24h"); useCacheEfficiency(period);
const { data: roleUsage, isLoading: loadingRoles } = useRoleUsage("24h"); const { data: roleUsage, isLoading: loadingRoles } = useRoleUsage(period);
const { data: waste, isLoading: loadingWaste } = useSpawnWaste("24h"); const { data: waste, isLoading: loadingWaste } = useSpawnWaste(period);
const trendUp = (summary?.trend_pct ?? 0) >= 0; const trendUp = (summary?.trend_pct ?? 0) >= 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Time window selector — drives every period-scoped hook below */}
<div className="flex justify-end">
<SegmentedControl
options={TIME_WINDOW_OPTIONS}
value={period}
onValueChange={(v) => setPeriod(v as UsagePeriod)}
aria-label="Usage time window"
/>
</div>
{/* Row 1 — Summary cards */} {/* Row 1 — Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 2xl:grid-cols-6"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 2xl:grid-cols-6">
<SummaryCard <SummaryCard
@@ -440,7 +472,7 @@ function TokenUsageCostsSection() {
isLoading={loadingSnap} isLoading={loadingSnap}
/> />
<SummaryCard <SummaryCard
title="Total Cost (24h)" title="Total Cost"
value={summary ? "$" + summary.total_cost_usd.toFixed(4) : undefined} value={summary ? "$" + summary.total_cost_usd.toFixed(4) : undefined}
icon={<Coins className="h-4 w-4 text-green-500" />} icon={<Coins className="h-4 w-4 text-green-500" />}
isLoading={loadingSnap} isLoading={loadingSnap}
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskStatusChart } from "../task-status-chart";
describe("TaskStatusChart", () => {
it("renders the card title", () => {
render(
<TaskStatusChart
slices={[
{ name: "Completed", value: 5 },
{ name: "Pending", value: 0 },
]}
/>,
);
expect(
screen.getByText("Task Status Distribution"),
).toBeInTheDocument();
});
it("shows an empty state when every slice is zero", () => {
render(
<TaskStatusChart
slices={[
{ name: "Pending", value: 0 },
{ name: "Completed", value: 0 },
]}
/>,
);
expect(screen.getByText("No tasks")).toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(
<TaskStatusChart
slices={[{ name: "Pending", value: 0 }]}
isLoading
/>,
);
expect(screen.queryByText("No tasks")).not.toBeInTheDocument();
});
});
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -11,6 +12,7 @@ import {
} from "recharts"; } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import type { AgentUsageRow } from "@/types"; import type { AgentUsageRow } from "@/types";
@@ -24,8 +26,14 @@ function fmtK(n: number): string {
return String(n); return String(n);
} }
const VIEW_OPTIONS = [
{ value: "chart", label: "Chart" },
{ value: "table", label: "Table" },
];
export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) { export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [view, setView] = useState<"chart" | "table">("chart");
// Fewer bars on a phone — 10 labels at ~30deg rotation still overlap below // Fewer bars on a phone — 10 labels at ~30deg rotation still overlap below
// ~400px, so cap the label density instead of shrinking text further. // ~400px, so cap the label density instead of shrinking text further.
const chartData = [...(data ?? [])] const chartData = [...(data ?? [])]
@@ -35,15 +43,51 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
name: row.agent_slug, name: row.agent_slug,
Tokens: row.total_tokens, Tokens: row.total_tokens,
})); }));
const tableRows = [...(data ?? [])].sort(
(a, b) => b.total_tokens - a.total_tokens,
);
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base">Agent Tokens Today</CardTitle> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Agent Tokens</CardTitle>
<SegmentedControl
options={VIEW_OPTIONS}
value={view}
onValueChange={(v) => setView(v as "chart" | "table")}
aria-label="Agent tokens view"
/>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
) : view === "table" ? (
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground text-xs">
<tr>
<th className="text-left font-medium py-1">Agent</th>
<th className="text-right font-medium py-1">Tokens</th>
<th className="text-right font-medium py-1">%</th>
</tr>
</thead>
<tbody>
{tableRows.map((row) => (
<tr key={row.agent_slug} className="border-t">
<td className="py-1 truncate">{row.agent_slug}</td>
<td className="py-1 text-right tabular-nums">
{row.total_tokens.toLocaleString()}
</td>
<td className="py-1 text-right tabular-nums">
{row.pct_of_total.toFixed(1)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : ( ) : (
<ResponsiveContainer width="100%" height={208}> <ResponsiveContainer width="100%" height={208}>
<BarChart <BarChart
+1
View File
@@ -2,4 +2,5 @@ export { UsageTimeSeriesChart } from "./usage-time-series-chart";
export { ModelUsageDonut } from "./model-usage-donut"; export { ModelUsageDonut } from "./model-usage-donut";
export { AgentUsageChart } from "./agent-usage-chart"; export { AgentUsageChart } from "./agent-usage-chart";
export { TeamUsageChart } from "./team-usage-chart"; export { TeamUsageChart } from "./team-usage-chart";
export { TaskStatusChart } from "./task-status-chart";
export { SessionsTable } from "./sessions-table"; export { SessionsTable } from "./sessions-table";
@@ -0,0 +1,86 @@
"use client";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
interface TaskStatusSlice {
name: string;
value: number;
}
interface TaskStatusChartProps {
slices: TaskStatusSlice[];
isLoading?: boolean;
}
const CHART_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
/**
* Task-status distribution donut for the Performance landing tab — the one
* chart that tab was missing (W9-2). Fed by the status counts already
* computed on the page; no extra hook.
*/
export function TaskStatusChart({ slices, isLoading }: TaskStatusChartProps) {
const chartData = slices.filter((s) => s.value > 0);
const total = chartData.reduce((sum, s) => sum + s.value, 0);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Task Status Distribution</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : total === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No tasks
</p>
) : (
<ResponsiveContainer width="100%" height={208}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={52}
outerRadius={80}
dataKey="value"
paddingAngle={3}
>
{chartData.map((_, idx) => (
<Cell
key={idx}
fill={CHART_COLORS[idx % CHART_COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value, name) => [
`${typeof value === "number" ? value : 0} tasks`,
name,
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -11,6 +12,7 @@ import {
} from "recharts"; } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import type { TeamUsageRow } from "@/types"; import type { TeamUsageRow } from "@/types";
@@ -24,23 +26,67 @@ function fmtK(n: number): string {
return String(n); return String(n);
} }
const VIEW_OPTIONS = [
{ value: "chart", label: "Chart" },
{ value: "table", label: "Table" },
];
export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) { export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [view, setView] = useState<"chart" | "table">("chart");
const chartData = [...(data ?? [])] const chartData = [...(data ?? [])]
.sort((a, b) => b.total_tokens - a.total_tokens) .sort((a, b) => b.total_tokens - a.total_tokens)
.map((row) => ({ .map((row) => ({
name: row.team.replace(/_/g, " "), name: row.team.replace(/_/g, " "),
Tokens: row.total_tokens, Tokens: row.total_tokens,
})); }));
const tableRows = [...(data ?? [])].sort(
(a, b) => b.total_tokens - a.total_tokens,
);
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Team Tokens</CardTitle> <CardTitle className="text-base">Team Tokens</CardTitle>
<SegmentedControl
options={VIEW_OPTIONS}
value={view}
onValueChange={(v) => setView(v as "chart" | "table")}
aria-label="Team tokens view"
/>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
) : view === "table" ? (
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground text-xs">
<tr>
<th className="text-left font-medium py-1">Team</th>
<th className="text-right font-medium py-1">Tokens</th>
<th className="text-right font-medium py-1">%</th>
</tr>
</thead>
<tbody>
{tableRows.map((row) => (
<tr key={row.team} className="border-t">
<td className="py-1 capitalize">
{row.team.replace(/_/g, " ")}
</td>
<td className="py-1 text-right tabular-nums">
{row.total_tokens.toLocaleString()}
</td>
<td className="py-1 text-right tabular-nums">
{row.pct_of_total.toFixed(1)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : ( ) : (
<ResponsiveContainer width="100%" height={208}> <ResponsiveContainer width="100%" height={208}>
<BarChart <BarChart
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SegmentedControl } from "../segmented-control";
const WINDOWS = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
{ value: "90d", label: "90d" },
];
describe("SegmentedControl", () => {
it("renders every option as a tab", () => {
render(
<SegmentedControl
options={WINDOWS}
value="24h"
onValueChange={() => {}}
aria-label="Time window"
/>,
);
for (const opt of WINDOWS) {
expect(screen.getByRole("tab", { name: opt.label })).toBeInTheDocument();
}
});
it("marks only the selected option active", () => {
render(
<SegmentedControl
options={WINDOWS}
value="7d"
onValueChange={() => {}}
aria-label="Time window"
/>,
);
expect(screen.getByRole("tab", { name: "7d" })).toHaveAttribute(
"data-state",
"active",
);
expect(screen.getByRole("tab", { name: "24h" })).toHaveAttribute(
"data-state",
"inactive",
);
});
it("calls onValueChange with the clicked option's value", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(
<SegmentedControl
options={WINDOWS}
value="24h"
onValueChange={onChange}
aria-label="Time window"
/>,
);
await user.click(screen.getByRole("tab", { name: "90d" }));
expect(onChange).toHaveBeenCalledWith("90d");
});
});
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export interface SegmentedOption {
value: string;
label: string;
}
interface SegmentedControlProps {
options: SegmentedOption[];
value: string;
onValueChange: (value: string) => void;
"aria-label"?: string;
className?: string;
}
/**
* Generic segmented control — a value + N mutually-exclusive options.
* Built on the Radix Tabs primitive (value/onValueChange, no panels).
* Used for the metrics time-window selector (24h/7d/30d/90d) and the
* chart/table view toggle — one primitive, two roles.
*/
export function SegmentedControl({
options,
value,
onValueChange,
className,
...rest
}: SegmentedControlProps) {
return (
<Tabs
value={value}
onValueChange={onValueChange}
className={className}
>
<TabsList {...rest}>
{options.map((opt) => (
<TabsTrigger key={opt.value} value={opt.value}>
{opt.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
}
+13 -8
View File
@@ -13,14 +13,19 @@ import type {
UsageSession, UsageSession,
} from "@/types"; } from "@/types";
export type UsagePeriod = "24h" | "7d" | "30d"; export type UsagePeriod = "24h" | "7d" | "30d" | "90d";
// ============================================================================= // =============================================================================
// MOCK DATA — shapes must exactly match the real backend response schemas // MOCK DATA — shapes must exactly match the real backend response schemas
// ============================================================================= // =============================================================================
/** Linear scale factor for mock aggregates so a longer window shows more volume. */
function scaleFor(period: UsagePeriod): number {
return period === "90d" ? 90 : period === "30d" ? 30 : period === "7d" ? 7 : 1;
}
function mockSummary(period: UsagePeriod = "24h"): UsageSummary { function mockSummary(period: UsagePeriod = "24h"): UsageSummary {
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1; const scale = scaleFor(period);
const base = 124_800 * scale; const base = 124_800 * scale;
return { return {
tokens_input: Math.round(base * 0.55), tokens_input: Math.round(base * 0.55),
@@ -34,7 +39,7 @@ function mockSummary(period: UsagePeriod = "24h"): UsageSummary {
function mockTimeSeries(period: UsagePeriod = "24h"): UsageTimePoint[] { function mockTimeSeries(period: UsagePeriod = "24h"): UsageTimePoint[] {
const now = new Date(); const now = new Date();
const points = period === "24h" ? 24 : period === "7d" ? 7 : 30; const points = period === "24h" ? 24 : period === "7d" ? 7 : period === "90d" ? 90 : 30;
const step = period === "24h" ? "hour" : "day"; const step = period === "24h" ? "hour" : "day";
return Array.from({ length: points }, (_, i) => { return Array.from({ length: points }, (_, i) => {
const ts = new Date(now); const ts = new Date(now);
@@ -59,7 +64,7 @@ function mockTimeSeries(period: UsagePeriod = "24h"): UsageTimePoint[] {
} }
function mockAgentUsage(period: UsagePeriod = "24h"): AgentUsageRow[] { function mockAgentUsage(period: UsagePeriod = "24h"): AgentUsageRow[] {
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1; const scale = scaleFor(period);
const agents = [ const agents = [
{ agent_slug: "be-dev-1" }, { agent_slug: "be-dev-1" },
{ agent_slug: "be-dev-2" }, { agent_slug: "be-dev-2" },
@@ -87,7 +92,7 @@ function mockAgentUsage(period: UsagePeriod = "24h"): AgentUsageRow[] {
} }
function mockTeamUsage(period: UsagePeriod = "24h"): TeamUsageRow[] { function mockTeamUsage(period: UsagePeriod = "24h"): TeamUsageRow[] {
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1; const scale = scaleFor(period);
const teams = ["backend", "frontend", "ux_ui", "main_pm"]; const teams = ["backend", "frontend", "ux_ui", "main_pm"];
const grand = teams.length * 50_000 * scale; const grand = teams.length * 50_000 * scale;
return teams.map((team) => { return teams.map((team) => {
@@ -106,7 +111,7 @@ function mockTeamUsage(period: UsagePeriod = "24h"): TeamUsageRow[] {
} }
function mockModelUsage(period: UsagePeriod = "24h"): ModelUsageSlice[] { function mockModelUsage(period: UsagePeriod = "24h"): ModelUsageSlice[] {
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1; const scale = scaleFor(period);
const models = [ const models = [
{ model: "claude-opus-4", share: 0.548 }, { model: "claude-opus-4", share: 0.548 },
{ model: "claude-sonnet-4", share: 0.346 }, { model: "claude-sonnet-4", share: 0.346 },
@@ -156,7 +161,7 @@ function mockCacheEfficiency(
} }
function mockRoleUsage(period: UsagePeriod = "24h"): RoleUsageRow[] { function mockRoleUsage(period: UsagePeriod = "24h"): RoleUsageRow[] {
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1; const scale = scaleFor(period);
const roles = [ const roles = [
{ role: "developer", share: 0.375 }, { role: "developer", share: 0.375 },
{ role: "main_pm", share: 0.301 }, { role: "main_pm", share: 0.301 },
@@ -187,7 +192,7 @@ function mockRoleUsage(period: UsagePeriod = "24h"): RoleUsageRow[] {
} }
function mockSpawnWaste(period: UsagePeriod = "24h"): SpawnWasteResponse { function mockSpawnWaste(period: UsagePeriod = "24h"): SpawnWasteResponse {
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1; const scale = scaleFor(period);
const by_role = [ const by_role = [
{ role: "developer", spawns: 51 * scale, unproductive: 42 * scale }, { role: "developer", spawns: 51 * scale, unproductive: 42 * scale },
{ role: "cell_pm", spawns: 34 * scale, unproductive: 18 * scale }, { role: "cell_pm", spawns: 34 * scale, unproductive: 18 * scale },
+4 -4
View File
@@ -2,7 +2,7 @@
Token Usage Analytics API Token Usage Analytics API
Provides endpoints for querying token usage metrics across agents, Provides endpoints for querying token usage metrics across agents,
teams, and models. Supports period-based queries (24h, 7d, 30d). teams, and models. Supports period-based queries (24h, 7d, 30d, 90d).
""" """
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
@@ -14,11 +14,11 @@ from roboco.services.usage import get_usage_service
router = APIRouter(dependencies=[Depends(require_panel_token)]) router = APIRouter(dependencies=[Depends(require_panel_token)])
_PeriodType = Literal["24h", "7d", "30d"] _PeriodType = Literal["24h", "7d", "30d", "90d"]
_PeriodQuery = Annotated[ _PeriodQuery = Annotated[
_PeriodType, _PeriodType,
Query(description="Time period: 24h, 7d, 30d"), Query(description="Time period: 24h, 7d, 30d, 90d"),
] ]
@@ -58,7 +58,7 @@ async def get_usage_time_series(
"""Return bucketed time-series data points. """Return bucketed time-series data points.
- 24h → hourly buckets - 24h → hourly buckets
- 7d / 30d → daily buckets - 7d / 30d / 90d → daily buckets
Each point has: bucket (ISO timestamp), tokens_input, tokens_output, Each point has: bucket (ISO timestamp), tokens_input, tokens_output,
total_tokens, cost_usd. total_tokens, cost_usd.
+4 -2
View File
@@ -59,7 +59,7 @@ def _session_row(row: Any) -> dict[str, Any]:
def _parse_period(period: str) -> tuple[datetime, int]: def _parse_period(period: str) -> tuple[datetime, int]:
"""Parse period string into (start_dt, hours). """Parse period string into (start_dt, hours).
Accepts '24h', '7d', '30d'. Defaults to 24h for unknown values. Accepts '24h', '7d', '30d', '90d'. Defaults to 24h for unknown values.
Returns (start_datetime_utc, total_hours). Returns (start_datetime_utc, total_hours).
""" """
now = datetime.now(UTC) now = datetime.now(UTC)
@@ -67,6 +67,8 @@ def _parse_period(period: str) -> tuple[datetime, int]:
return now - timedelta(days=7), 7 * 24 return now - timedelta(days=7), 7 * 24
if period == "30d": if period == "30d":
return now - timedelta(days=30), 30 * 24 return now - timedelta(days=30), 30 * 24
if period == "90d":
return now - timedelta(days=90), 90 * 24
# default 24h # default 24h
return now - timedelta(hours=24), 24 return now - timedelta(hours=24), 24
@@ -176,7 +178,7 @@ class UsageService(BaseService):
"""Return bucketed time-series data points. """Return bucketed time-series data points.
- 24h hourly buckets - 24h hourly buckets
- 7d / 30d daily buckets - 7d / 30d / 90d daily buckets
Each point has: bucket (ISO string), tokens_input, tokens_output, Each point has: bucket (ISO string), tokens_input, tokens_output,
total_tokens, cost_usd. total_tokens, cost_usd.
+40 -1
View File
@@ -17,7 +17,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID from uuid import UUID
import pytest import pytest
from roboco.services.usage import UsageService from roboco.services.usage import UsageService, _parse_period
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Named constants (ruff PLR2004: magic values in comparisons must be named). # Named constants (ruff PLR2004: magic values in comparisons must be named).
@@ -101,6 +101,45 @@ def _service_with_execute(*return_values: object) -> UsageService:
return UsageService(session) return UsageService(session)
# ---------------------------------------------------------------------------
# _parse_period — period string → (start_dt, hours)
# ---------------------------------------------------------------------------
class TestParsePeriod:
"""_parse_period maps each period string to (start_dt, hours).
90d is the new window (W9-2); the others pin the existing contract so a
future refactor can't silently drop a window.
"""
_HOURS_PER_DAY = 24
def test_24h_default(self) -> None:
start, hours = _parse_period("24h")
assert hours == self._HOURS_PER_DAY
elapsed_h = (datetime.datetime.now(datetime.UTC) - start).total_seconds() / 3600
assert elapsed_h == pytest.approx(self._HOURS_PER_DAY, abs=1)
def test_7d(self) -> None:
_, hours = _parse_period("7d")
assert hours == 7 * self._HOURS_PER_DAY
def test_30d(self) -> None:
_, hours = _parse_period("30d")
assert hours == 30 * self._HOURS_PER_DAY
def test_90d(self) -> None:
start, hours = _parse_period("90d")
assert hours == 90 * self._HOURS_PER_DAY
elapsed_h = (datetime.datetime.now(datetime.UTC) - start).total_seconds() / 3600
assert elapsed_h == pytest.approx(90 * self._HOURS_PER_DAY, abs=1)
def test_unknown_defaults_to_24h(self) -> None:
_, hours = _parse_period("bogus")
assert hours == self._HOURS_PER_DAY
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_summary — trend_pct arithmetic # get_summary — trend_pct arithmetic
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------