feat(ui): page the Spend Per Tag card through the team tags report

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mrinal 2026-10-21 16:04:11 +00:00
parent e0b8d4d08c
commit c8eddb7af6
8 changed files with 210 additions and 193 deletions

View file

@ -12,6 +12,7 @@ import type {
ModelActivityData,
SpendMetrics,
} from "@/components/UsagePage/types";
import type { DailyActivityTagPageResponse } from "@/components/UsagePage/dailyActivityApi";
import EntityUsage from "./EntityUsage";
import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations";
@ -34,6 +35,37 @@ const emptySpendMetrics: SpendMetrics = {
cache_creation_input_tokens: 0,
};
const tagSpendRow = (
tag: string,
spend: number,
apiRequests: number,
): DailyActivityTagPageResponse["tags"][number] => ({
tag,
spend,
prompt_tokens: apiRequests * 10,
completion_tokens: apiRequests * 5,
total_tokens: apiRequests * 15,
api_requests: apiRequests,
successful_requests: apiRequests,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const tagSpendPage = (offset: number): DailyActivityTagPageResponse => ({
tags:
offset === 0
? [
tagSpendRow("project:rag-v2", 0.25, 1),
tagSpendRow("cost-center:cc-42", 0.5, 2),
...Array.from({ length: 48 }, (_, index) => tagSpendRow(`tag-${index}`, 0.01, 1)),
]
: Array.from({ length: 10 }, (_, index) => tagSpendRow(`next-${index}`, 0.01, 1)),
total_tags: 60,
offset,
limit: 50,
});
const createKeyMetrics = (spend: number, metadata: KeyMetadata): KeyMetricWithMetadata => ({
metrics: { ...emptySpendMetrics, spend },
metadata,
@ -68,6 +100,7 @@ vi.mock("@/components/networking", () => ({
dailyActivityKeyPageCall: vi.fn(),
dailyActivityKeySearchCall: vi.fn(),
dailyActivityModelTopKeysCall: vi.fn(),
dailyActivityTagPageCall: vi.fn(),
dailyActivityExportCall: vi.fn(),
}));
@ -165,6 +198,7 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
describe("EntityUsage", () => {
const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall);
const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall);
const mockDailyActivityTagPageCall = vi.mocked(networking.dailyActivityTagPageCall);
const mockTagDailyActivityCall = vi.fn();
const mockTeamDailyActivityCall = vi.fn();
const mockOrganizationDailyActivityCall = vi.fn();
@ -476,6 +510,7 @@ describe("EntityUsage", () => {
beforeEach(() => {
mockDailyActivityAggregatedCall.mockReset();
mockDailyActivityKeyPageCall.mockReset();
mockDailyActivityTagPageCall.mockReset();
const emptyKeyPage = {
api_keys: [],
total_api_keys: 0,
@ -483,6 +518,7 @@ describe("EntityUsage", () => {
limit: 50,
};
mockDailyActivityKeyPageCall.mockResolvedValue(emptyKeyPage);
mockDailyActivityTagPageCall.mockImplementation((request) => Promise.resolve(tagSpendPage(request.offset)));
mockDailyActivityAggregatedCall.mockImplementation((entity, request) =>
(
entityMocks[entity] as unknown as (
@ -610,44 +646,7 @@ describe("EntityUsage", () => {
expect(screen.queryByText(/^team-user-spend:/)).not.toBeInTheDocument();
});
const tagSpendFor = (tags: Record<string, { spend: number; api_requests: number }>) => ({
results: [
{
date: "2025-01-01",
metrics: { ...emptySpendMetrics },
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: Object.fromEntries(
Object.entries(tags).map(([tag, { spend, api_requests }]) => [
tag,
{
metrics: { ...emptySpendMetrics, spend, api_requests, successful_requests: api_requests },
metadata: {},
api_key_breakdown: {},
},
]),
),
},
},
],
metadata: { total_spend: 0, total_api_requests: 0 },
});
it("shows one selected team's spend per tag from a group_by=tag request", async () => {
mockTeamDailyActivityCall.mockImplementation((request: { groupBy?: string }) =>
Promise.resolve(
request.groupBy === "tag"
? tagSpendFor({
"project:rag-v2": { spend: 0.25, api_requests: 1 },
"cost-center:cc-42": { spend: 0.5, api_requests: 2 },
})
: mockSpendData,
),
);
it("shows one selected team's tag page in API order and loads the next page", async () => {
const user = userEvent.setup();
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
@ -657,14 +656,22 @@ describe("EntityUsage", () => {
const card = await screen.findByTestId("team-tag-spend-card");
expect(within(card).getByRole("heading", { name: "Spend Per Tag" })).toBeInTheDocument();
const rows = (await within(card).findAllByRole("row")).slice(1);
expect(rows.map((row) => within(row).getAllByRole("cell")[0].textContent)).toEqual([
"cost-center:cc-42",
expect(rows.slice(0, 2).map((row) => within(row).getAllByRole("cell")[0].textContent)).toEqual([
"project:rag-v2",
"cost-center:cc-42",
]);
expect(within(rows[0]).getByText("$0.5000")).toBeInTheDocument();
expect(mockTeamDailyActivityCall).toHaveBeenCalledWith(
expect.objectContaining({ entityIds: ["team-1"], groupBy: "tag" }),
expect(within(card).getByText("Showing 1-50 of 60")).toBeInTheDocument();
expect(mockDailyActivityTagPageCall).toHaveBeenCalledWith(
expect.objectContaining({ teamId: "team-1", offset: 0, limit: 50 }),
);
await user.click(within(card).getByRole("button", { name: "Go to next page" }));
await waitFor(() =>
expect(mockDailyActivityTagPageCall).toHaveBeenCalledWith(
expect.objectContaining({ teamId: "team-1", offset: 50, limit: 50 }),
),
);
expect(await within(card).findByText("Showing 51-60 of 60")).toBeInTheDocument();
});
it("asks for tag spend only for exactly one team in the team view", async () => {
@ -702,7 +709,7 @@ describe("EntityUsage", () => {
});
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
expect(mockTeamDailyActivityCall).not.toHaveBeenCalledWith(expect.objectContaining({ groupBy: "tag" }));
expect(mockDailyActivityTagPageCall).not.toHaveBeenCalled();
});
it("should render with organization entity type and call organization API", async () => {

View file

@ -1,16 +1,17 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Tags } from "lucide-react";
import React, { useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { DataTable, DataTablePagination } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { toDailyData, type DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi";
import type {
DailyActivityTagPageResponse,
DailyActivityTagSpendRow,
} from "@/components/UsagePage/dailyActivityApi";
import { useAggregatedDailyActivity } from "../../hooks/useAggregatedDailyActivity";
import { Panel } from "../overview/Primitives";
import { ENTITY_API } from "./entityFetchFns";
import { teamTagSpendRows, type TeamTagSpendRow } from "./teamTagSpend";
interface TeamTagSpendCardProps {
accessToken: string | null;
@ -19,9 +20,26 @@ interface TeamTagSpendCardProps {
teamId: string;
}
const QUIET_HEADER = { headerClassName: "font-normal" };
interface TagPageState {
scope: object;
page: number;
response: DailyActivityTagPageResponse | null;
failed: boolean;
}
const columns: ColumnDef<TeamTagSpendRow>[] = [
interface TeamTagSpendPage {
page: number;
loading: boolean;
failed: boolean;
rows: DailyActivityTagSpendRow[];
totalTags: number;
onPageChange: (nextPage: number) => void;
}
const QUIET_HEADER = { headerClassName: "font-normal" };
const PAGE_SIZE = 50;
const columns: ColumnDef<DailyActivityTagSpendRow>[] = [
{
header: "Tag",
accessorKey: "tag",
@ -60,21 +78,68 @@ const columns: ColumnDef<TeamTagSpendRow>[] = [
},
];
const TeamTagSpendCard: React.FC<TeamTagSpendCardProps> = ({ accessToken, startTime, endTime, teamId }) => {
const tagSpendRequest: DailyActivityRequest = {
accessToken: accessToken as string,
startTime: startTime as Date,
endTime: endTime as Date,
entityIds: [teamId],
groupBy: "tag",
};
const { data, loading, failed } = useAggregatedDailyActivity({
fetch: () => ENTITY_API.team.aggregated(tagSpendRequest),
enabled: Boolean(accessToken && startTime && endTime),
deps: [accessToken, startTime, endTime, teamId],
});
const rows = useMemo(() => teamTagSpendRows(toDailyData(data)), [data]);
const useTeamTagSpendPage = ({
accessToken,
startTime,
endTime,
teamId,
}: TeamTagSpendCardProps): TeamTagSpendPage => {
const startTimestamp = startTime?.getTime();
const endTimestamp = endTime?.getTime();
const requestScope = useMemo(
() => ({ hasAccessToken: accessToken !== null, endTimestamp, startTimestamp, teamId }),
[accessToken, endTimestamp, startTimestamp, teamId],
);
const [pageState, setPageState] = useState<{ scope: object; page: number }>({ scope: {}, page: 0 });
const [tagPageState, setTagPageState] = useState<TagPageState | null>(null);
const page = pageState.scope === requestScope ? pageState.page : 0;
const currentScopeState = tagPageState?.scope === requestScope ? tagPageState : null;
const currentPageState = currentScopeState?.page === page ? currentScopeState : null;
const enabled = Boolean(accessToken && startTimestamp !== undefined && endTimestamp !== undefined);
useEffect(() => {
if (!accessToken || startTimestamp === undefined || endTimestamp === undefined) return;
const controller = new AbortController();
const tagPageRequest = {
accessToken,
startTime: new Date(startTimestamp),
endTime: new Date(endTimestamp),
teamId,
offset: page * PAGE_SIZE,
limit: PAGE_SIZE,
signal: controller.signal,
};
void ENTITY_API.team.tagPage(tagPageRequest)
.then((response) => {
if (controller.signal.aborted) return;
const nextPageState: TagPageState = { scope: requestScope, page, response, failed: false };
setTagPageState(nextPageState);
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
console.error("Team tag spend page request failed:", error);
setTagPageState((current) => ({
scope: requestScope,
page,
response: current?.scope === requestScope ? current.response : null,
failed: true,
}));
});
return () => controller.abort();
}, [accessToken, endTimestamp, page, requestScope, startTimestamp, teamId]);
const loading = enabled && currentPageState === null;
const failed = currentPageState?.failed ?? false;
const rows = failed ? [] : (currentPageState?.response?.tags ?? []);
const totalTags = currentScopeState?.response?.total_tags ?? 0;
const onPageChange = (nextPage: number) => setPageState({ scope: requestScope, page: nextPage });
return { page, loading, failed, rows, totalTags, onPageChange };
};
const TeamTagSpendCard: React.FC<TeamTagSpendCardProps> = (props) => {
const { page, loading, failed, rows, totalTags, onPageChange } = useTeamTagSpendPage(props);
return (
<Panel icon={Tags} title="Spend Per Tag" testId="team-tag-spend-card">
<p className="mb-3 text-xs text-muted-foreground">
@ -91,6 +156,17 @@ const TeamTagSpendCard: React.FC<TeamTagSpendCardProps> = ({ accessToken, startT
}
size="compact"
/>
{totalTags > 0 && (
<DataTablePagination
page={page}
pageSize={PAGE_SIZE}
rowCount={totalTags}
onPageChange={onPageChange}
onPageSizeChange={() => undefined}
pageSizeOptions={[PAGE_SIZE]}
isLoading={loading}
/>
)}
</Panel>
);
};

View file

@ -4,6 +4,7 @@ import {
dailyActivityKeyPageCall,
dailyActivityKeySearchCall,
dailyActivityModelTopKeysCall,
dailyActivityTagPageCall,
} from "@/components/networking";
import type { EntityType } from "@/components/EntityUsageExport/types";
import type {
@ -12,6 +13,8 @@ import type {
DailyActivityKeySearchResponse,
DailyActivityKeyPageResponse,
DailyActivityRequest,
DailyActivityTagPageRequest,
DailyActivityTagPageResponse,
ExportFormat,
ExportType,
ModelTopKeysResponse,
@ -30,6 +33,10 @@ export interface EntityApi {
exportRows(req: DailyActivityRequest, exportType: ExportType, format: ExportFormat): Promise<Blob>;
}
export interface TeamEntityApi extends EntityApi {
tagPage(req: DailyActivityTagPageRequest): Promise<DailyActivityTagPageResponse>;
}
const entityApi = (
entity: DailyActivityEntity,
defaults?: Pick<DailyActivityRequest, "excludeEntityIds">,
@ -42,9 +49,12 @@ const entityApi = (
exportRows: (req, exportType, format) => dailyActivityExportCall(entity, { ...defaults, ...req }, exportType, format),
});
export const ENTITY_API: Record<EntityType, EntityApi> = {
export const ENTITY_API: Record<EntityType, EntityApi> & { team: TeamEntityApi } = {
tag: entityApi("tag"),
team: entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }),
team: {
...entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }),
tagPage: dailyActivityTagPageCall,
},
organization: entityApi("organization"),
customer: entityApi("customer"),
agent: entityApi("agent"),

View file

@ -1,74 +1,6 @@
import { describe, expect, it } from "vitest";
import type { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import { singleTeamId, teamTagSpendRows } from "./teamTagSpend";
const metrics = (spend: number, api_requests: number, failed_requests = 0): SpendMetrics => ({
spend,
prompt_tokens: 10 * api_requests,
completion_tokens: 5 * api_requests,
total_tokens: 15 * api_requests,
api_requests,
successful_requests: api_requests - failed_requests,
failed_requests,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const entity = (spend: number, api_requests: number, failed_requests = 0): MetricWithMetadata => ({
metrics: metrics(spend, api_requests, failed_requests),
metadata: {},
api_key_breakdown: {},
});
const day = (date: string, entities: Record<string, MetricWithMetadata>): DailyData => ({
date,
metrics: metrics(0, 0),
breakdown: { models: {}, model_groups: {}, mcp_servers: {}, providers: {}, api_keys: {}, entities },
});
describe("teamTagSpendRows", () => {
it("sums each tag across days and orders by spend, highest first", () => {
const rows = teamTagSpendRows([
day("2026-10-18", { "cost-center:cc-42": entity(0.5, 2), "project:rag-v2": entity(0.25, 1) }),
day("2026-10-19", { "cost-center:cc-42": entity(0.25, 1, 1), "env:prod": entity(1, 4) }),
]);
expect(rows).toEqual([
{ tag: "env:prod", spend: 1, api_requests: 4, successful_requests: 4, failed_requests: 0, total_tokens: 60 },
{
tag: "cost-center:cc-42",
spend: 0.75,
api_requests: 3,
successful_requests: 2,
failed_requests: 1,
total_tokens: 45,
},
{
tag: "project:rag-v2",
spend: 0.25,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
total_tokens: 15,
},
]);
});
it("drops tags with no spend and breaks spend ties by tag name", () => {
const rows = teamTagSpendRows([
day("2026-10-19", { "b-tag": entity(0.5, 1), "a-tag": entity(0.5, 1), "free-tag": entity(0, 3) }),
]);
expect(rows.map((row) => row.tag)).toEqual(["a-tag", "b-tag"]);
});
it("returns no rows when the team sent no tagged spend", () => {
expect(teamTagSpendRows([day("2026-10-19", {})])).toEqual([]);
expect(teamTagSpendRows([])).toEqual([]);
});
});
import { singleTeamId } from "./teamTagSpend";
describe("singleTeamId", () => {
it("is the team only when exactly one is selected", () => {

View file

@ -1,44 +1,2 @@
import type { DailyData } from "@/components/UsagePage/types";
export interface TeamTagSpendRow {
tag: string;
spend: number;
api_requests: number;
successful_requests: number;
failed_requests: number;
total_tokens: number;
}
const emptyRow = (tag: string): TeamTagSpendRow => ({
tag,
spend: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
total_tokens: 0,
});
/** One row per tag summed across days, zero-spend tags dropped, highest spend first (ties by tag name). */
export const teamTagSpendRows = (days: readonly DailyData[]): TeamTagSpendRow[] => {
const byTag = new Map<string, TeamTagSpendRow>();
for (const day of days) {
for (const [tag, { metrics }] of Object.entries(day.breakdown.entities ?? {})) {
const row = byTag.get(tag) ?? emptyRow(tag);
const summed: TeamTagSpendRow = {
tag,
spend: row.spend + metrics.spend,
api_requests: row.api_requests + metrics.api_requests,
successful_requests: row.successful_requests + metrics.successful_requests,
failed_requests: row.failed_requests + metrics.failed_requests,
total_tokens: row.total_tokens + metrics.total_tokens,
};
byTag.set(tag, summed);
}
}
return [...byTag.values()]
.filter((row) => row.spend > 0)
.sort((a, b) => b.spend - a.spend || a.tag.localeCompare(b.tag));
};
export const singleTeamId = (selectedTeamIds: readonly string[]): string | null =>
selectedTeamIds.length === 1 ? selectedTeamIds[0] : null;

View file

@ -1,4 +1,4 @@
import type { components, operations } from "@/lib/http/schema";
import type { components } from "@/lib/http/schema";
import type { BreakdownMetrics, DailyData, KeyMetadata, KeyMetricWithMetadata, MetricWithMetadata } from "./types";
export type DailyActivityEntity = "user" | "team" | "tag" | "organization" | "customer" | "agent";
@ -11,11 +11,23 @@ export type KeyActivityRow = components["schemas"]["KeyActivityRow"];
export type KeySpendActivityRow = components["schemas"]["KeySpendActivityRow"];
export type DailyActivityKeySearchResponse = components["schemas"]["DailyActivityKeySearchResponse"];
export type DailyActivityKeyPageResponse = components["schemas"]["DailyActivityKeyPageResponse"];
export type DailyActivityTagPageResponse = components["schemas"]["DailyActivityTagPageResponse"];
export type DailyActivityTagSpendRow = components["schemas"]["TagSpendRow"];
export type ModelTopKeysResponse = components["schemas"]["ModelTopKeysResponse"];
export type CacheLeakageKeysResponse = components["schemas"]["CacheLeakageKeysResponse"];
export type UserActivityRow = components["schemas"]["UserActivityRow"];
export type DailyActivityUserPageResponse = components["schemas"]["DailyActivityUserPageResponse"];
export interface DailyActivityTagPageRequest {
accessToken: string;
startTime: Date;
endTime: Date;
teamId: string;
offset: number;
limit: number;
signal?: AbortSignal;
}
export interface DailyActivityRequest {
accessToken: string;
startTime: Date;
@ -26,14 +38,8 @@ export interface DailyActivityRequest {
model?: string | null;
includeCurrentUtcDay?: boolean;
apiKeyLimit?: number;
groupBy?: TeamGroupBy;
}
type TeamAggregatedQuery = NonNullable<
operations["get_team_daily_activity_aggregated_team_daily_activity_aggregated_get"]["parameters"]["query"]
>;
export type TeamGroupBy = NonNullable<TeamAggregatedQuery["group_by"]>;
export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = {
has_more: false,
page: 1,

View file

@ -6,6 +6,7 @@ import {
dailyActivityExportCall,
dailyActivityKeySearchCall,
dailyActivityModelTopKeysCall,
dailyActivityTagPageCall,
} from "./networking";
import type { DailyActivityEntity, DailyActivityRequest } from "./UsagePage/dailyActivityApi";
@ -62,6 +63,7 @@ describe("dailyActivityAggregatedCall", () => {
expect(url.searchParams.has("timezone")).toBe(true);
expect(url.searchParams.has("page")).toBe(false);
expect(url.searchParams.has("page_size")).toBe(false);
expect(url.searchParams.has("group_by")).toBe(false);
});
it("omits the entity filter when no ids are given", async () => {
@ -123,16 +125,27 @@ describe("dailyActivityAggregatedCall", () => {
expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true");
});
it("sends group_by on team reports only, and only when set", async () => {
it("GETs the team tags page with team, offset, and limit", async () => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall("team", req({ entityIds: ["team-a"], groupBy: "tag" }));
await dailyActivityAggregatedCall("team", req({ entityIds: ["team-a"] }));
await dailyActivityAggregatedCall("tag", req({ entityIds: ["tag-a"], groupBy: "tag" }));
const request = {
accessToken: "sk-key",
startTime: start,
endTime: end,
teamId: "team-a",
offset: 50,
limit: 50,
};
await dailyActivityTagPageCall(request);
expect(requestedUrl(mockFetch, 0).searchParams.get("group_by")).toBe("tag");
expect(requestedUrl(mockFetch, 1).searchParams.has("group_by")).toBe(false);
expect(requestedUrl(mockFetch, 2).searchParams.has("group_by")).toBe(false);
const url = requestedUrl(mockFetch);
expect(url.pathname).toBe("/team/daily/activity/aggregated/tags");
expect(url.searchParams.get("team_ids")).toBe("team-a");
expect(url.searchParams.get("start_date")).toBe("2025-01-05");
expect(url.searchParams.get("end_date")).toBe("2025-01-31");
expect(url.searchParams.get("offset")).toBe("50");
expect(url.searchParams.get("limit")).toBe("50");
expect(url.searchParams.has("group_by")).toBe(false);
});
it("sends api_key_limit only when provided", async () => {

View file

@ -144,6 +144,8 @@ import type {
DailyActivityKeyPageResponse,
DailyActivityKeySearchResponse,
DailyActivityRequest,
DailyActivityTagPageRequest,
DailyActivityTagPageResponse,
DailyActivityUserPageResponse,
ExportFormat,
ExportType,
@ -1381,7 +1383,6 @@ const dailyActivityQuery = (
: {}),
timezone: new Date().getTimezoneOffset().toString(),
include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined,
group_by: entity === "team" ? req.groupBy : undefined,
...extra,
};
};
@ -1406,6 +1407,20 @@ export const dailyActivityKeyPageCall = (
query: dailyActivityQuery(entity, req, { offset, limit }),
});
export const dailyActivityTagPageCall = (req: DailyActivityTagPageRequest): Promise<DailyActivityTagPageResponse> => {
const dailyActivityRequest: DailyActivityRequest = {
accessToken: req.accessToken,
startTime: req.startTime,
endTime: req.endTime,
entityIds: [req.teamId],
};
return apiClient.get<DailyActivityTagPageResponse>(`/team/daily/activity/aggregated/tags`, {
accessToken: req.accessToken,
query: dailyActivityQuery("team", dailyActivityRequest, { offset: req.offset, limit: req.limit }),
...(req.signal ? { signal: req.signal } : {}),
});
};
export const dailyActivityKeySearchCall = (
entity: DailyActivityEntity,
req: DailyActivityRequest,