@@ -90,10 +96,17 @@ export function TopModelsTable({ data, loading }: TopModelsTableProps) {
{sorted.map((row) => {
- const isActive = filters.model === row.key;
- const [model, provider] = row.key.includes("/")
- ? row.key.split("/", 2)
- : [row.key, "—"];
+ const { provider, model } = parseProviderModelKey(row.key);
+ const isActive = filters.model === model && (!provider || filters.provider === provider);
+ const toggleProviderModel = () => {
+ if (isActive) {
+ setFilter("provider", undefined);
+ setFilter("model", undefined);
+ return;
+ }
+ setFilter("provider", provider || undefined);
+ setFilter("model", model);
+ };
return (
toggleFilter("model", row.key)}
+ onClick={toggleProviderModel}
>
| {model} |
- {provider} |
+ {provider || "—"} |
{row.llm_call_count.toLocaleString()} |
{formatTokens(row.input_tokens)} |
{formatTokens(row.output_tokens)} |
{formatDuration(row.avg_duration_ms)} |
- {formatCost(row.total_cost)} |
+ {formatApiCost(row.total_cost)} |
);
})}
diff --git a/ui/web/src/pages/usage/components/usage-cap-row.tsx b/ui/web/src/pages/usage/components/usage-cap-row.tsx
index 99786c2d..ec3b5ad0 100644
--- a/ui/web/src/pages/usage/components/usage-cap-row.tsx
+++ b/ui/web/src/pages/usage/components/usage-cap-row.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Pencil, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { formatCost, formatTokens } from "@/lib/format";
+import { formatApiCost, formatCost, formatTokens } from "@/lib/format";
import type { UsageCapPolicy, UsageCapUtilization } from "@/types/usage-caps";
interface UsageCapRowProps {
@@ -31,7 +31,7 @@ export function UsageCapRow({ row, onEdit, onDelete }: UsageCapRowProps) {
{t(`caps.windows.${p.window}`)} |
{p.max_tokens ? `${formatTokens(tokenUsed)} / ${formatTokens(p.max_tokens)} (${tokenPct}%)` : "-"} |
- {p.max_cost_micros ? `${formatCost(costUsed / 1_000_000)} / ${formatCost(p.max_cost_micros / 1_000_000)} (${costPct}%)` : "-"} |
+ {p.max_cost_micros ? `${formatApiCost(costUsed / 1_000_000)} / ${formatCost(p.max_cost_micros / 1_000_000)} (${costPct}%)` : "-"} |
{p.enabled ? t("caps.enabled") : t("caps.disabled")} |
diff --git a/ui/web/src/pages/usage/components/usage-caps-panel.tsx b/ui/web/src/pages/usage/components/usage-caps-panel.tsx
index 972f1c34..ce09b471 100644
--- a/ui/web/src/pages/usage/components/usage-caps-panel.tsx
+++ b/ui/web/src/pages/usage/components/usage-caps-panel.tsx
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
-import { formatCost, formatDate, formatTokens } from "@/lib/format";
+import { formatApiCost, formatDate, formatTokens } from "@/lib/format";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useUsageCaps } from "../hooks/use-usage-caps";
@@ -183,7 +183,7 @@ export function UsageCapsPanel() {
{blockedEvents.slice(0, 4).map((event) => (
{event.reason || t("caps.blocked")}
- {formatDate(event.created_at)} · {formatTokens(event.estimated_tokens)} · {formatCost(event.estimated_cost_micros / 1_000_000)}
+ {formatDate(event.created_at)} · {formatTokens(event.estimated_tokens)} · {formatApiCost(event.estimated_cost_micros / 1_000_000)}
))}
diff --git a/ui/web/src/pages/usage/components/usage-event-analytics-panel.tsx b/ui/web/src/pages/usage/components/usage-event-analytics-panel.tsx
index 186325c0..6089588a 100644
--- a/ui/web/src/pages/usage/components/usage-event-analytics-panel.tsx
+++ b/ui/web/src/pages/usage/components/usage-event-analytics-panel.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Cable, Sparkles, Terminal, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { formatCost, formatDuration, formatTokens } from "@/lib/format";
+import { formatApiCost, formatDuration, formatTokens } from "@/lib/format";
import { cn } from "@/lib/utils";
import { useUsageFilterContext } from "../context/usage-filter-context";
import { useUsageEventAnalytics, type UsageEventResourceType } from "../hooks/use-usage-event-analytics";
@@ -66,7 +66,7 @@ export function UsageEventAnalyticsPanel() {
-
+
@@ -116,7 +116,7 @@ export function UsageEventAnalyticsPanel() {
{rowErrorRate.toFixed(1)}% |
{formatDuration(row.avg_duration_ms)} |
{formatTokens(row.total_tokens)} |
- {formatCost(row.cost_usd)} |
+ {formatApiCost(row.cost_usd)} |
);
})
diff --git a/ui/web/src/pages/usage/context/usage-filter-context.tsx b/ui/web/src/pages/usage/context/usage-filter-context.tsx
index 76bc9d0b..cea59803 100644
--- a/ui/web/src/pages/usage/context/usage-filter-context.tsx
+++ b/ui/web/src/pages/usage/context/usage-filter-context.tsx
@@ -23,7 +23,7 @@ interface UsageFilterContextValue {
activeFilterCount: number;
}
-function buildTimeRange(period: Period): { from: string; to: string; granularity: "hour" | "day" } {
+export function buildTimeRange(period: Period): { from: string; to: string; granularity: "hour" | "day" } {
const now = new Date();
let from: Date;
let granularity: "hour" | "day";
diff --git a/ui/web/src/pages/usage/hooks/use-usage-analytics.ts b/ui/web/src/pages/usage/hooks/use-usage-analytics.ts
index 37e44e6b..72b40f54 100644
--- a/ui/web/src/pages/usage/hooks/use-usage-analytics.ts
+++ b/ui/web/src/pages/usage/hooks/use-usage-analytics.ts
@@ -1,6 +1,7 @@
+import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
-import type { UsageFilters } from "../context/usage-filter-context";
+import { buildTimeRange, type UsageFilters } from "../context/usage-filter-context";
export interface SnapshotTimeSeries {
bucket_time: string;
@@ -50,9 +51,10 @@ interface SummaryResponse {
}
function buildParams(filters: UsageFilters, extra?: Record ): Record {
+ const range = filters.period === "custom" ? filters : buildTimeRange(filters.period);
const p: Record = {
- from: filters.from,
- to: filters.to,
+ from: range.from,
+ to: range.to,
};
if (filters.agentId) p.agent_id = filters.agentId;
if (filters.provider) p.provider = filters.provider;
@@ -66,9 +68,14 @@ function filterKey(f: UsageFilters) {
return [f.from, f.to, f.agentId, f.provider, f.model, f.channel] as const;
}
-// Snapshots update hourly — no need to re-fetch on window focus or within 60s.
-const STALE_TIME = 60_000;
-const QUERY_OPTS = { staleTime: STALE_TIME, refetchOnWindowFocus: false } as const;
+// Current-hour usage is filled from live traces, so keep the open dashboard fresh.
+const REFRESH_INTERVAL = 30_000;
+const QUERY_OPTS = {
+ staleTime: REFRESH_INTERVAL,
+ refetchInterval: REFRESH_INTERVAL,
+ refetchIntervalInBackground: false,
+ refetchOnWindowFocus: true,
+} as const;
export function useUsageAnalytics(filters: UsageFilters) {
const http = useHttp();
@@ -98,6 +105,14 @@ export function useUsageAnalytics(filters: UsageFilters) {
...QUERY_OPTS,
});
+ const providerModelQuery = useQuery({
+ queryKey: ["usage", "breakdown", "provider_model", ...fk],
+ queryFn: () =>
+ http.get<{ rows: SnapshotBreakdown[] }>("/v1/usage/breakdown", buildParams(filters, { group_by: "provider_model" })),
+ placeholderData: (prev) => prev,
+ ...QUERY_OPTS,
+ });
+
const channelQuery = useQuery({
queryKey: ["usage", "breakdown", "channel", ...fk],
queryFn: () =>
@@ -114,22 +129,51 @@ export function useUsageAnalytics(filters: UsageFilters) {
...QUERY_OPTS,
});
+ const refreshAnalytics = useCallback(async () => {
+ await Promise.all([
+ timeseriesQuery.refetch(),
+ providerQuery.refetch(),
+ modelQuery.refetch(),
+ providerModelQuery.refetch(),
+ channelQuery.refetch(),
+ summaryQuery.refetch(),
+ ]);
+ }, [
+ timeseriesQuery.refetch,
+ providerQuery.refetch,
+ modelQuery.refetch,
+ providerModelQuery.refetch,
+ channelQuery.refetch,
+ summaryQuery.refetch,
+ ]);
+
// isLoading = first mount only (no cached data) → shows skeleton.
// placeholderData keeps previous results visible during refetch → no flicker.
const loading =
timeseriesQuery.isLoading ||
providerQuery.isLoading ||
modelQuery.isLoading ||
+ providerModelQuery.isLoading ||
channelQuery.isLoading ||
summaryQuery.isLoading;
+ const refreshing =
+ timeseriesQuery.isFetching ||
+ providerQuery.isFetching ||
+ modelQuery.isFetching ||
+ providerModelQuery.isFetching ||
+ channelQuery.isFetching ||
+ summaryQuery.isFetching;
return {
timeseries: timeseriesQuery.data?.points ?? [],
providerBreakdown: providerQuery.data?.rows ?? [],
modelBreakdown: modelQuery.data?.rows ?? [],
+ providerModelBreakdown: providerModelQuery.data?.rows ?? [],
channelBreakdown: channelQuery.data?.rows ?? [],
summary: summaryQuery.data ?? null,
loading,
+ refreshing,
+ refreshAnalytics,
error: timeseriesQuery.error,
};
}
diff --git a/ui/web/src/pages/usage/hooks/use-usage-event-analytics.ts b/ui/web/src/pages/usage/hooks/use-usage-event-analytics.ts
index ba5fbf2c..835af8fa 100644
--- a/ui/web/src/pages/usage/hooks/use-usage-event-analytics.ts
+++ b/ui/web/src/pages/usage/hooks/use-usage-event-analytics.ts
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
-import type { UsageFilters } from "../context/usage-filter-context";
+import { buildTimeRange, type UsageFilters } from "../context/usage-filter-context";
export type UsageEventResourceType = "tool" | "skill" | "mcp_tool" | "runtime_tool";
@@ -50,9 +50,10 @@ export interface UsageEventTimeSeries {
}
function buildParams(filters: UsageFilters, resourceType: UsageEventResourceType, extra?: Record): Record {
+ const range = filters.period === "custom" ? filters : buildTimeRange(filters.period);
const p: Record = {
- from: filters.from,
- to: filters.to,
+ from: range.from,
+ to: range.to,
resource_type: resourceType,
};
if (filters.agentId) p.agent_id = filters.agentId;
@@ -66,7 +67,13 @@ function filterKey(f: UsageFilters, resourceType: UsageEventResourceType) {
return [resourceType, f.from, f.to, f.agentId, f.provider, f.model, f.channel, f.granularity] as const;
}
-const QUERY_OPTS = { staleTime: 60_000, refetchOnWindowFocus: false } as const;
+const REFRESH_INTERVAL = 30_000;
+const QUERY_OPTS = {
+ staleTime: REFRESH_INTERVAL,
+ refetchInterval: REFRESH_INTERVAL,
+ refetchIntervalInBackground: false,
+ refetchOnWindowFocus: true,
+} as const;
export function useUsageEventAnalytics(filters: UsageFilters, resourceType: UsageEventResourceType) {
const http = useHttp();
diff --git a/ui/web/src/pages/usage/hooks/use-usage.ts b/ui/web/src/pages/usage/hooks/use-usage.ts
index 950b2b06..454a131f 100644
--- a/ui/web/src/pages/usage/hooks/use-usage.ts
+++ b/ui/web/src/pages/usage/hooks/use-usage.ts
@@ -11,6 +11,7 @@ export interface UsageRecord {
inputTokens: number;
outputTokens: number;
totalTokens: number;
+ cost: number;
timestamp: number;
}
diff --git a/ui/web/src/pages/usage/usage-page.tsx b/ui/web/src/pages/usage/usage-page.tsx
index b23b2b3e..9cf30359 100644
--- a/ui/web/src/pages/usage/usage-page.tsx
+++ b/ui/web/src/pages/usage/usage-page.tsx
@@ -8,7 +8,7 @@ import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { Pagination } from "@/components/shared/pagination";
import { ErrorBoundary } from "@/components/shared/error-boundary";
-import { formatTokens, formatCost } from "@/lib/format";
+import { formatTokens, formatApiCost } from "@/lib/format";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useUsage } from "./hooks/use-usage";
import { useUsageAnalytics } from "./hooks/use-usage-analytics";
@@ -30,7 +30,7 @@ function AnalyticsDashboard() {
const { t } = useTranslation("usage");
const { filters, setFilter } = useUsageFilterContext();
const { agents } = useAgents();
- const { timeseries, providerBreakdown, modelBreakdown, channelBreakdown, summary, loading, error } =
+ const { timeseries, providerBreakdown, modelBreakdown, providerModelBreakdown, channelBreakdown, summary, loading, refreshing, refreshAnalytics, error } =
useUsageAnalytics(filters);
// Legacy records table state
@@ -42,9 +42,20 @@ function AnalyticsDashboard() {
const setPageSize = (size: number) => { setPageSizeRaw(size); setPage(1); setGlobalPageSize(size); };
const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ const loadCurrentRecords = useCallback(() => {
+ return loadRecords({ limit: pageSize, offset: (page - 1) * pageSize, agentId: filters.agentId });
+ }, [loadRecords, page, pageSize, filters.agentId]);
+
useEffect(() => {
- loadRecords({ limit: pageSize, offset: (page - 1) * pageSize, agentId: filters.agentId });
- }, [page, pageSize, filters.agentId]);
+ loadCurrentRecords();
+ }, [loadCurrentRecords]);
+
+ useEffect(() => {
+ const id = window.setInterval(() => {
+ void loadCurrentRecords();
+ }, 30_000);
+ return () => window.clearInterval(id);
+ }, [loadCurrentRecords]);
const agentList = agents.map((a) => ({
id: a.id,
@@ -75,10 +86,15 @@ function AnalyticsDashboard() {
URL.revokeObjectURL(url);
}, [timeseries, filters.period]);
+ const handleRefresh = useCallback(() => {
+ void Promise.all([refreshAnalytics(), loadCurrentRecords()]);
+ }, [refreshAnalytics, loadCurrentRecords]);
+
const current = summary?.current ?? EMPTY_SUMMARY;
const previous = summary?.previous ?? EMPTY_SUMMARY;
const apiError = error instanceof Error ? error.message : error ? String(error) : null;
+ const isRefreshing = recLoading || refreshing;
return (
@@ -86,8 +102,8 @@ function AnalyticsDashboard() {
title={t("analytics.title")}
description={t("description")}
actions={
- |