feat: merge Limits + Usage into unified Spend page (#24093)

This commit is contained in:
Danielle Maywood
2026-04-09 13:17:03 +01:00
committed by GitHub
parent 27e5ff0a8e
commit 29ad2c6201
29 changed files with 2351 additions and 1625 deletions
@@ -113,14 +113,14 @@ This setting is available under **Agents** > **Settings** > **Behavior**.
The maximum configurable value is 30 days. When disabled, workspaces follow
their template's autostop rules (or none, if the template does not define any).
### Usage limits and analytics
### Spend management
Administrators can set spend limits to cap LLM usage per user within a rolling
time period, with per-user and per-group overrides. The cost tracking dashboard
provides visibility into per-user spending, token consumption, and per-model
breakdowns.
See [Usage & Analytics](./usage-insights.md) for details.
See [Spend Management](./usage-insights.md) for details.
### Data retention
@@ -1,11 +1,11 @@
# Usage and Analytics
# Spend Management
Coder provides two admin-only views for monitoring and controlling agent
Coder provides admin-only controls for monitoring and controlling agent
spend: usage limits and cost tracking.
## Usage limits
Navigate to **Agents** > **Settings** > **Limits**.
Navigate to **Agents** > **Settings** > **Spend**.
Usage limits cap how much each user can spend on LLM usage within a rolling
time period. When enabled, the system checks the user's current spend before
@@ -53,7 +53,7 @@ their effective limit, current spend, and when the current period resets.
## Cost tracking
Navigate to **Agents** > **Settings** > **Usage**.
Navigate to **Agents** > **Settings** > **Spend**.
This view shows deployment-wide LLM chat costs with per-user drill-down.
+1 -1
View File
@@ -1246,7 +1246,7 @@
"state": ["early access"]
},
{
"title": "Usage \u0026 Analytics",
"title": "Spend Management",
"description": "Spend limits and cost tracking for Coder Agents",
"path": "./ai-coder/agents/platform-controls/usage-insights.md",
"state": ["early access"]
+29 -27
View File
@@ -7,8 +7,6 @@ import {
cancelChatListRefetches,
chatCostSummary,
chatCostSummaryKey,
chatCostUsers,
chatCostUsersKey,
chatDiffContentsKey,
chatKey,
chatMessagesKey,
@@ -20,6 +18,7 @@ import {
infiniteChats,
interruptChat,
invalidateChatListQueries,
paginatedChatCostUsers,
pinChat,
promoteChatQueuedMessage,
regenerateChatTitle,
@@ -176,20 +175,6 @@ describe("invalidateChatListQueries", () => {
).toBe(true);
});
it("does not invalidate chatCostUsersKey", async () => {
const queryClient = createTestQueryClient();
queryClient.setQueryData(chatCostUsersKey(undefined), {});
queryClient.setQueryData(chatsKey, [makeChat("chat-1")]);
await invalidateChatListQueries(queryClient);
expect(
queryClient.getQueryState(chatCostUsersKey(undefined))?.isInvalidated,
"chatCostUsersKey should NOT be invalidated",
).not.toBe(true);
});
it("does not invalidate a different chat's queries", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
@@ -631,25 +616,42 @@ describe("chat cost query factories", () => {
);
});
it("builds a distinct users query key and forwards snake_case params", async () => {
const params = {
it("builds paginated cost users query with correct key and coerces empty username", async () => {
const payload = {
start_date: "2025-01-01",
end_date: "2025-01-31",
username: "alice",
limit: 10,
offset: 20,
username: "",
};
vi.mocked(API.experimental.getChatCostUsers).mockResolvedValue(
{} as TypesGen.ChatCostUsersResponse,
);
const result = paginatedChatCostUsers(payload);
const query = chatCostUsers(params);
// queryPayload returns the original payload.
const pageParams = {
pageNumber: 2,
limit: 25,
offset: 25,
searchParams: new URLSearchParams(),
};
expect(result.queryPayload(pageParams)).toEqual(payload);
expect(chatCostUsersKey(params)).toEqual(["chats", "costUsers", params]);
expect(query.queryKey).toEqual(["chats", "costUsers", params]);
expect(query.queryKey).not.toEqual(chatCostSummaryKey("me", params));
await query.queryFn();
expect(API.experimental.getChatCostUsers).toHaveBeenCalledWith(params);
// queryKey includes the payload and page number.
const key = result.queryKey({ ...pageParams, payload });
expect(key).toEqual(["chats", "costUsers", payload, 2]);
// queryFn coerces empty username to undefined.
// Cast needed because PaginatedQueryFnContext includes
// react-query internal fields that aren't relevant here.
await (
result.queryFn as (params: Record<string, unknown>) => Promise<unknown>
)({
...pageParams,
payload,
});
expect(API.experimental.getChatCostUsers).toHaveBeenCalledWith(
expect.objectContaining({ username: undefined, limit: 25, offset: 25 }),
);
});
});
+27 -13
View File
@@ -5,6 +5,7 @@ import type {
} from "react-query";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
export const chatsKey = ["chats"] as const;
export const chatKey = (chatId: string) => ["chats", chatId] as const;
@@ -992,12 +993,6 @@ type ChatCostDateParams = {
end_date?: string;
};
type ChatCostUsersParams = ChatCostDateParams & {
username?: string;
limit?: number;
offset?: number;
};
export const chatCostSummaryKey = (user = "me", params?: ChatCostDateParams) =>
[...chatsKey, "costSummary", user, params] as const;
@@ -1007,14 +1002,33 @@ export const chatCostSummary = (user = "me", params?: ChatCostDateParams) => ({
staleTime: 60_000,
});
export const chatCostUsersKey = (params?: ChatCostUsersParams) =>
[...chatsKey, "costUsers", params] as const;
interface PaginatedChatCostUsersPayload {
username: string;
start_date: string;
end_date: string;
}
export const chatCostUsers = (params?: ChatCostUsersParams) => ({
queryKey: chatCostUsersKey(params),
queryFn: () => API.experimental.getChatCostUsers(params),
staleTime: 60_000,
});
export function paginatedChatCostUsers(
payload: PaginatedChatCostUsersPayload,
): UsePaginatedQueryOptions<
TypesGen.ChatCostUsersResponse,
PaginatedChatCostUsersPayload
> {
return {
queryPayload: () => payload,
queryKey: ({ payload, pageNumber }) =>
[...chatsKey, "costUsers", payload, pageNumber] as const,
queryFn: ({ payload, limit, offset }) =>
API.experimental.getChatCostUsers({
start_date: payload.start_date,
end_date: payload.end_date,
username: payload.username || undefined,
limit,
offset,
}),
staleTime: 60_000,
};
}
const prInsightsKey = (params?: { start_date?: string; end_date?: string }) =>
[...chatsKey, "prInsights", params] as const;
@@ -1,94 +0,0 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatUsageLimitConfig,
deleteChatUsageLimitGroupOverride,
deleteChatUsageLimitOverride,
updateChatUsageLimitConfig,
upsertChatUsageLimitGroupOverride,
upsertChatUsageLimitOverride,
} from "#/api/queries/chats";
import { groups } from "#/api/queries/groups";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { LimitsTab } from "./components/LimitsTab";
const AgentSettingsLimitsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
// Queries.
const configQuery = useQuery(chatUsageLimitConfig());
const groupsQuery = useQuery(groups());
// Mutations.
const updateConfigMutation = useMutation(
updateChatUsageLimitConfig(queryClient),
);
const upsertOverrideMutation = useMutation(
upsertChatUsageLimitOverride(queryClient),
);
const deleteOverrideMutation = useMutation(
deleteChatUsageLimitOverride(queryClient),
);
const upsertGroupOverrideMutation = useMutation(
upsertChatUsageLimitGroupOverride(queryClient),
);
const deleteGroupOverrideMutation = useMutation(
deleteChatUsageLimitGroupOverride(queryClient),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<LimitsTab
configData={configQuery.data}
isLoadingConfig={configQuery.isLoading}
configError={configQuery.isError ? configQuery.error : null}
refetchConfig={() => void configQuery.refetch()}
groupsData={groupsQuery.data}
isLoadingGroups={groupsQuery.isLoading}
groupsError={groupsQuery.isError ? groupsQuery.error : null}
onUpdateConfig={(req) => updateConfigMutation.mutateAsync(req)}
isUpdatingConfig={updateConfigMutation.isPending}
updateConfigError={
updateConfigMutation.isError ? updateConfigMutation.error : null
}
isUpdateConfigSuccess={updateConfigMutation.isSuccess}
resetUpdateConfig={() => updateConfigMutation.reset()}
onUpsertOverride={(args) => upsertOverrideMutation.mutateAsync(args)}
isUpsertingOverride={upsertOverrideMutation.isPending}
upsertOverrideError={
upsertOverrideMutation.isError ? upsertOverrideMutation.error : null
}
onDeleteOverride={(userID) =>
deleteOverrideMutation.mutateAsync(userID)
}
isDeletingOverride={deleteOverrideMutation.isPending}
deleteOverrideError={
deleteOverrideMutation.isError ? deleteOverrideMutation.error : null
}
onUpsertGroupOverride={(args) =>
upsertGroupOverrideMutation.mutateAsync(args)
}
isUpsertingGroupOverride={upsertGroupOverrideMutation.isPending}
upsertGroupOverrideError={
upsertGroupOverrideMutation.isError
? upsertGroupOverrideMutation.error
: null
}
onDeleteGroupOverride={(groupID) =>
deleteGroupOverrideMutation.mutateAsync(groupID)
}
isDeletingGroupOverride={deleteGroupOverrideMutation.isPending}
deleteGroupOverrideError={
deleteGroupOverrideMutation.isError
? deleteGroupOverrideMutation.error
: null
}
/>
</RequirePermission>
);
};
export default AgentSettingsLimitsPage;
@@ -0,0 +1,234 @@
import dayjs from "dayjs";
import { type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useSearchParams } from "react-router";
import {
chatCostSummary,
chatUsageLimitConfig,
deleteChatUsageLimitGroupOverride,
deleteChatUsageLimitOverride,
paginatedChatCostUsers,
updateChatUsageLimitConfig,
upsertChatUsageLimitGroupOverride,
upsertChatUsageLimitOverride,
} from "#/api/queries/chats";
import { groups } from "#/api/queries/groups";
import { user } from "#/api/queries/users";
import type { ChatCostUserRollup } from "#/api/typesGenerated";
import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker";
import { useDebouncedValue } from "#/hooks/debounce";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsSpendPageView } from "./AgentSettingsSpendPageView";
const startDateSearchParam = "startDate";
const endDateSearchParam = "endDate";
const DEFAULT_DATE_RANGE_DAYS = 30;
const SEARCH_DEBOUNCE_MS = 300;
const getDefaultDateRange = (now?: dayjs.Dayjs): DateRangeValue => {
const end = now ?? dayjs();
return {
startDate: end.subtract(DEFAULT_DATE_RANGE_DAYS, "day").toDate(),
endDate: end.toDate(),
};
};
interface AgentSettingsSpendPageProps {
/** Override the current time for date range calculation. Used for
* deterministic Storybook snapshots. */
now?: dayjs.Dayjs;
}
const AgentSettingsSpendPage: FC<AgentSettingsSpendPageProps> = ({ now }) => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
// --------------- Limits queries & mutations ---------------
const configQuery = useQuery(chatUsageLimitConfig());
const groupsQuery = useQuery(groups());
const updateConfigMutation = useMutation(
updateChatUsageLimitConfig(queryClient),
);
const upsertOverrideMutation = useMutation(
upsertChatUsageLimitOverride(queryClient),
);
const deleteOverrideMutation = useMutation(
deleteChatUsageLimitOverride(queryClient),
);
const upsertGroupOverrideMutation = useMutation(
upsertChatUsageLimitGroupOverride(queryClient),
);
const deleteGroupOverrideMutation = useMutation(
deleteChatUsageLimitGroupOverride(queryClient),
);
// --------------- Usage state & queries ---------------
const [searchParams, setSearchParams] = useSearchParams();
const searchFilter = searchParams.get("search") ?? "";
const debouncedSearch = useDebouncedValue(searchFilter, SEARCH_DEBOUNCE_MS);
const setSearchFilter = (value: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value) {
next.set("search", value);
} else {
next.delete("search");
}
// Reset to page 1 when the search changes.
next.delete("page");
return next;
},
{ replace: true },
);
};
const startDateParam = searchParams.get(startDateSearchParam)?.trim() ?? "";
const endDateParam = searchParams.get(endDateSearchParam)?.trim() ?? "";
// Stable default so dayjs() isn't called on every render.
const [defaultDateRange] = useState(() => getDefaultDateRange(now));
let dateRange = defaultDateRange;
let endDateIsExclusive = false;
if (startDateParam && endDateParam) {
const parsedStartDate = new Date(startDateParam);
const parsedEndDate = new Date(endDateParam);
if (
!Number.isNaN(parsedStartDate.getTime()) &&
!Number.isNaN(parsedEndDate.getTime()) &&
parsedStartDate.getTime() <= parsedEndDate.getTime()
) {
dateRange = {
startDate: parsedStartDate,
endDate: parsedEndDate,
};
endDateIsExclusive = true;
}
}
const dateRangeParams = {
start_date: dateRange.startDate.toISOString(),
end_date: dateRange.endDate.toISOString(),
};
const onDateRangeChange = (value: DateRangeValue) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set(startDateSearchParam, value.startDate.toISOString());
next.set(endDateSearchParam, value.endDate.toISOString());
// Reset pagination when date range changes.
next.delete("page");
return next;
});
};
const usersQuery = usePaginatedQuery(
paginatedChatCostUsers({
...dateRangeParams,
username: debouncedSearch,
}),
);
const selectedUserId = searchParams.get("user") || null;
const selectedUserQuery = useQuery({
...user(selectedUserId ?? ""),
enabled: selectedUserId !== null,
});
const summaryQuery = useQuery({
...chatCostSummary(selectedUserId ?? "me", dateRangeParams),
enabled: selectedUserId !== null,
});
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsSpendPageView
// Limits config
configData={configQuery.data}
isLoadingConfig={configQuery.isLoading}
configError={configQuery.isError ? configQuery.error : null}
refetchConfig={() => void configQuery.refetch()}
groupsData={groupsQuery.data}
isLoadingGroups={groupsQuery.isLoading}
groupsError={groupsQuery.isError ? groupsQuery.error : null}
onUpdateConfig={updateConfigMutation.mutate}
isUpdatingConfig={updateConfigMutation.isPending}
updateConfigError={
updateConfigMutation.isError ? updateConfigMutation.error : null
}
isUpdateConfigSuccess={updateConfigMutation.isSuccess}
resetUpdateConfig={updateConfigMutation.reset}
onUpsertOverride={({ userID, req, onSuccess }) =>
upsertOverrideMutation.mutate({ userID, req }, { onSuccess })
}
isUpsertingOverride={upsertOverrideMutation.isPending}
upsertOverrideError={
upsertOverrideMutation.isError ? upsertOverrideMutation.error : null
}
onDeleteOverride={deleteOverrideMutation.mutate}
isDeletingOverride={deleteOverrideMutation.isPending}
deleteOverrideError={
deleteOverrideMutation.isError ? deleteOverrideMutation.error : null
}
onUpsertGroupOverride={({ groupID, req, onSuccess }) =>
upsertGroupOverrideMutation.mutate({ groupID, req }, { onSuccess })
}
isUpsertingGroupOverride={upsertGroupOverrideMutation.isPending}
upsertGroupOverrideError={
upsertGroupOverrideMutation.isError
? upsertGroupOverrideMutation.error
: null
}
onDeleteGroupOverride={deleteGroupOverrideMutation.mutate}
isDeletingGroupOverride={deleteGroupOverrideMutation.isPending}
deleteGroupOverrideError={
deleteGroupOverrideMutation.isError
? deleteGroupOverrideMutation.error
: null
}
// Usage data
dateRange={dateRange}
endDateIsExclusive={endDateIsExclusive}
onDateRangeChange={onDateRangeChange}
searchFilter={searchFilter}
onSearchFilterChange={setSearchFilter}
usersQuery={usersQuery}
drillInUserId={selectedUserId}
drillInUser={selectedUserQuery.data ?? null}
isDrillInUserLoading={selectedUserQuery.isLoading}
isDrillInUserError={selectedUserQuery.isError}
drillInUserError={selectedUserQuery.error}
onDrillInUserRetry={() => void selectedUserQuery.refetch()}
onClearSelectedUser={() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("user");
return next;
});
}}
onSelectUser={(u: ChatCostUserRollup) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("user", u.user_id);
return next;
});
}}
summaryData={summaryQuery.data}
isSummaryLoading={summaryQuery.isLoading}
summaryError={summaryQuery.error}
onSummaryRetry={() => void summaryQuery.refetch()}
/>
</RequirePermission>
);
};
export default AgentSettingsSpendPage;
@@ -0,0 +1,515 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer";
import { AgentSettingsSpendPageView } from "./AgentSettingsSpendPageView";
// ── Mock data ──────────────────────────────────────────────────
const mockUsers: TypesGen.ChatCostUserRollup[] = [
{
user_id: "user-1",
username: "alice",
name: "Alice Liddell",
avatar_url: "",
total_cost_micros: 2_500_000,
message_count: 42,
chat_count: 5,
total_input_tokens: 200_000,
total_output_tokens: 300_000,
total_cache_read_tokens: 10_000,
total_cache_creation_tokens: 5_000,
total_runtime_ms: 0,
},
{
user_id: "user-2",
username: "bob",
name: "Bob Builder",
avatar_url: "",
total_cost_micros: 1_000_000,
message_count: 18,
chat_count: 3,
total_input_tokens: 80_000,
total_output_tokens: 120_000,
total_cache_read_tokens: 4_000,
total_cache_creation_tokens: 2_000,
total_runtime_ms: 0,
},
];
const mockUsersResponse: TypesGen.ChatCostUsersResponse = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: mockUsers.length,
users: mockUsers,
};
const mockUserProfile = {
id: "user-1",
username: "alice",
name: "Alice Liddell",
email: "alice@example.com",
avatar_url: "",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-06-01T00:00:00Z",
status: "active",
organization_ids: [],
roles: [],
last_seen_at: "2026-03-11T10:00:00Z",
login_type: "password",
has_ai_seat: false,
} as TypesGen.User;
const mockCostSummary = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 2_500_000,
priced_message_count: 40,
unpriced_message_count: 2,
total_input_tokens: 200_000,
total_output_tokens: 300_000,
total_cache_read_tokens: 10_000,
total_cache_creation_tokens: 5_000,
total_runtime_ms: 0,
by_model: [
{
model_config_id: "model-1",
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 2_000_000,
message_count: 30,
total_input_tokens: 150_000,
total_output_tokens: 250_000,
total_cache_read_tokens: 8_000,
total_cache_creation_tokens: 4_000,
total_runtime_ms: 0,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Refactor auth module",
total_cost_micros: 1_200_000,
message_count: 15,
total_input_tokens: 80_000,
total_output_tokens: 120_000,
total_cache_read_tokens: 3_000,
total_cache_creation_tokens: 1_500,
total_runtime_ms: 0,
},
],
} as TypesGen.ChatCostSummary;
const mockConfigData = {
spend_limit_micros: 50_000_000,
period: "month",
updated_at: "2026-03-01T00:00:00Z",
unpriced_model_count: 0,
overrides: [
{
user_id: "user-3",
username: "dave",
name: "Dave Grohl",
avatar_url: "",
spend_limit_micros: 100_000_000,
},
{
user_id: "user-4",
username: "charlie",
name: "Charlie Chaplin",
avatar_url: "",
spend_limit_micros: 25_000_000,
},
],
group_overrides: [
{
group_id: "group-1",
group_name: "engineering",
group_display_name: "Engineering",
group_avatar_url: "",
member_count: 12,
spend_limit_micros: 75_000_000,
},
],
} as TypesGen.ChatUsageLimitConfigResponse;
const mockGroups = [
{
id: "group-1",
name: "engineering",
display_name: "Engineering",
organization_id: "org-1",
members: [],
total_member_count: 12,
avatar_url: "",
quota_allowance: 0,
source: "user",
organization_name: "default",
organization_display_name: "Default",
},
{
id: "group-2",
name: "design",
display_name: "Design",
organization_id: "org-1",
members: [],
total_member_count: 5,
avatar_url: "",
quota_allowance: 0,
source: "user",
organization_name: "default",
organization_display_name: "Default",
},
] as TypesGen.Group[];
const defaultDateRange = {
startDate: new Date("2026-02-10T00:00:00Z"),
endDate: new Date("2026-03-12T00:00:00Z"),
};
// Helper to build a mock usersQuery object that satisfies the view's
// PaginationResult & query shape.
function mockUsersQuery(
opts: {
data?: TypesGen.ChatCostUsersResponse;
isLoading?: boolean;
isFetching?: boolean;
error?: unknown;
} = {},
): PaginationResult & {
data: TypesGen.ChatCostUsersResponse | undefined;
isLoading: boolean;
isFetching: boolean;
error: unknown;
refetch: () => unknown;
} {
const data = opts.data;
const isSuccess = data !== undefined && !opts.error;
return {
data,
isLoading: opts.isLoading ?? false,
isFetching: opts.isFetching ?? false,
error: opts.error ?? null,
refetch: fn(),
isPlaceholderData: false,
currentPage: 1,
limit: 25,
onPageChange: fn(),
goToPreviousPage: fn(),
goToNextPage: fn(),
goToFirstPage: fn(),
...(isSuccess
? {
isSuccess: true as const,
hasNextPage: false,
hasPreviousPage: false,
totalRecords: data.count,
totalPages: 1,
currentOffsetStart: data.count === 0 ? 0 : 1,
countIsCapped: false,
}
: {
isSuccess: false as const,
hasNextPage: false,
hasPreviousPage: false,
totalRecords: undefined,
totalPages: undefined,
currentOffsetStart: undefined,
countIsCapped: false,
}),
};
}
// Baseline props shared across stories. Only primitives and simple
// objects here to avoid the composeStory deep-merge hang.
const baseProps = {
// Limits config.
configData: undefined as TypesGen.ChatUsageLimitConfigResponse | undefined,
isLoadingConfig: false,
configError: null as Error | null,
groupsData: undefined as TypesGen.Group[] | undefined,
isLoadingGroups: false,
groupsError: null as Error | null,
isUpdatingConfig: false,
updateConfigError: null as Error | null,
isUpdateConfigSuccess: false,
isUpsertingOverride: false,
upsertOverrideError: null as Error | null,
isDeletingOverride: false,
deleteOverrideError: null as Error | null,
isUpsertingGroupOverride: false,
upsertGroupOverrideError: null as Error | null,
isDeletingGroupOverride: false,
deleteGroupOverrideError: null as Error | null,
// Usage data.
dateRange: defaultDateRange,
endDateIsExclusive: false,
searchFilter: "",
usersQuery: mockUsersQuery(),
drillInUserId: null as string | null,
drillInUser: null as TypesGen.User | null,
isDrillInUserLoading: false,
isDrillInUserError: false,
drillInUserError: undefined as unknown,
summaryData: undefined as TypesGen.ChatCostSummary | undefined,
isSummaryLoading: false,
summaryError: undefined as unknown,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsSpendPageView",
component: AgentSettingsSpendPageView,
args: {
...baseProps,
refetchConfig: fn(),
onUpdateConfig: fn(),
resetUpdateConfig: fn(),
onUpsertOverride: fn(),
onDeleteOverride: fn(),
onUpsertGroupOverride: fn(),
onDeleteGroupOverride: fn(),
onDateRangeChange: fn(),
onSearchFilterChange: fn(),
onDrillInUserRetry: fn(),
onClearSelectedUser: fn(),
onSelectUser: fn(),
onSummaryRetry: fn(),
},
} satisfies Meta<typeof AgentSettingsSpendPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsSpendPageView>;
// ── Stories ────────────────────────────────────────────────────
export const SpendWithLimitsAndUsers: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
usersQuery: mockUsersQuery({ data: mockUsersResponse }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// The header and all three collapsible sections should render.
await canvas.findByText("Spend management");
await expect(canvas.getByText("Default spend limit")).toBeInTheDocument();
await expect(canvas.getByText("Group limits")).toBeInTheDocument();
await expect(canvas.getByText("Per-user spend")).toBeInTheDocument();
// User table rows should be visible.
await expect(await canvas.findByText("Alice Liddell")).toBeInTheDocument();
await expect(canvas.getByText("Bob Builder")).toBeInTheDocument();
// Search field should be present.
await expect(
canvas.getByPlaceholderText("Search by name or username"),
).toBeInTheDocument();
},
};
export const SpendUsersEmpty: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
usersQuery: mockUsersQuery({
data: {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 0,
users: [],
},
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Spend management");
await expect(
await canvas.findByText("No usage data for this period."),
).toBeInTheDocument();
},
};
export const SpendUserDrillIn: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
drillInUserId: "user-1",
drillInUser: mockUserProfile,
summaryData: mockCostSummary,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Detail view shows user info.
await canvas.findByText(`User ID: ${mockUserProfile.id}`);
await expect(canvas.getByText("Alice Liddell")).toBeInTheDocument();
await expect(canvas.getByText("@alice")).toBeInTheDocument();
// The Back button should be visible.
await expect(canvas.getByText("Back")).toBeInTheDocument();
},
};
export const SpendUserDrillInAndBack: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
drillInUserId: "user-1",
drillInUser: mockUserProfile,
summaryData: mockCostSummary,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText(`User ID: ${mockUserProfile.id}`);
// Click Back.
await userEvent.click(canvas.getByText("Back"));
// The onClearSelectedUser callback should have been called.
expect(args.onClearSelectedUser).toHaveBeenCalled();
},
};
export const SpendDrillInLoading: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
drillInUserId: "user-1",
drillInUser: null,
isDrillInUserLoading: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByRole("status", { name: "Loading user details" }),
).toBeInTheDocument();
},
};
export const SpendDrillInError: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
drillInUserId: "user-1",
drillInUser: null,
isDrillInUserError: true,
drillInUserError: new Error("User not found"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("User not found");
await expect(canvas.getByText("Retry")).toBeInTheDocument();
},
};
export const SpendRefetchOverlay: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
usersQuery: mockUsersQuery({
data: mockUsersResponse,
isFetching: true,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Table data should be visible behind the overlay.
await canvas.findByText("Alice Liddell");
// The refetch overlay spinner should be shown.
await expect(
await canvas.findByRole("status", { name: "Refreshing usage" }),
).toBeInTheDocument();
},
};
export const SpendConfigLoading: Story = {
args: {
isLoadingConfig: true,
},
};
export const SpendConfigError: Story = {
args: {
configError: new Error("Network error: failed to fetch config"),
usersQuery: mockUsersQuery({ data: mockUsersResponse }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// The inline config error should be visible.
await canvas.findByText("Network error: failed to fetch config");
await expect(canvas.getByText("Retry")).toBeInTheDocument();
// The usage table should still render despite the config error.
await expect(canvas.getByText("Alice Liddell")).toBeInTheDocument();
await expect(canvas.getByText("Bob Builder")).toBeInTheDocument();
},
};
export const SpendUsersLoading: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
usersQuery: mockUsersQuery({ isLoading: true }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Config sections should still render.
await canvas.findByText("Default spend limit");
// The loading spinner for users should be visible.
await expect(
await canvas.findByRole("status", { name: "Loading usage" }),
).toBeInTheDocument();
},
};
export const SpendUsersError: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
usersQuery: mockUsersQuery({
error: new Error("Failed to load usage data"),
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Config sections should still render.
await canvas.findByText("Default spend limit");
// The usage error should be visible with retry.
await expect(
canvas.getByText("Failed to load usage data"),
).toBeInTheDocument();
await expect(canvas.getByText("Retry")).toBeInTheDocument();
},
};
export const SpendUserClickToDrillIn: Story = {
args: {
configData: mockConfigData,
groupsData: mockGroups,
usersQuery: mockUsersQuery({ data: mockUsersResponse }),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Click the first user row.
const row = await canvas.findByRole("button", {
name: /Alice Liddell/,
});
await userEvent.click(row);
expect(args.onSelectUser).toHaveBeenCalledWith(
expect.objectContaining({ user_id: "user-1" }),
);
},
};
@@ -0,0 +1,611 @@
import type { FC } from "react";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { Button } from "#/components/Button/Button";
import {
DateRangePicker,
type DateRangeValue,
} from "#/components/DateRangePicker/DateRangePicker";
import {
PaginationContainer,
type PaginationResult,
} from "#/components/PaginationWidget/PaginationContainer";
import { SearchField } from "#/components/SearchField/SearchField";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
import { formatTokenCount } from "#/utils/analytics";
import {
dollarsToMicros,
formatCostMicros,
microsToDollars,
} from "#/utils/currency";
import { AdminBadge } from "./components/AdminBadge";
import {
DefaultLimitController,
type DefaultLimitFormValues,
} from "./components/LimitsTab/DefaultLimitController";
import { DefaultLimitSection } from "./components/LimitsTab/DefaultLimitSection";
import { GroupLimitsSection } from "./components/LimitsTab/GroupLimitsSection";
import { GroupOverrideController } from "./components/LimitsTab/GroupOverrideController";
import { normalizeChatUsageLimitPeriod } from "./components/LimitsTab/limitsFormLogic";
import { UserOverrideController } from "./components/LimitsTab/UserOverrideController";
import { UserOverridesSection } from "./components/LimitsTab/UserOverridesSection";
import { SectionHeader } from "./components/SectionHeader";
import { SpendDrillInView } from "./components/SpendDrillInView";
import { formatUsageDateRange, toInclusiveDateRange } from "./utils/dateRange";
// ── UserRow sub-component ──
const UserRow: FC<{
user: TypesGen.ChatCostUserRollup;
onSelect: (user: TypesGen.ChatCostUserRollup) => void;
}> = ({ user, onSelect }) => {
const clickableRowProps = useClickableTableRow({
onClick: () => onSelect(user),
});
return (
<TableRow
{...clickableRowProps}
aria-label={`View details for ${user.name || user.username}`}
className="text-xs"
>
<TableCell className="max-w-[200px] px-3 py-2">
<Tooltip>
<TooltipTrigger asChild>
<div>
<AvatarData
title={
<span className="block truncate">
{user.name || user.username}
</span>
}
subtitle={
<span className="block truncate">@{user.username}</span>
}
src={user.avatar_url}
imgFallbackText={user.username}
/>
</div>
</TooltipTrigger>
<TooltipContent>{user.name || user.username}</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCostMicros(user.total_cost_micros)}
</TableCell>
<TableCell className="text-right tabular-nums">
{user.message_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{user.chat_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_input_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_output_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_cache_read_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_cache_creation_tokens)}
</TableCell>
</TableRow>
);
};
// ── Props ──
interface AgentSettingsSpendPageViewProps {
// Limits config.
configData: TypesGen.ChatUsageLimitConfigResponse | undefined;
isLoadingConfig: boolean;
configError: Error | null;
refetchConfig: () => void;
groupsData: TypesGen.Group[] | undefined;
isLoadingGroups: boolean;
groupsError: Error | null;
onUpdateConfig: (req: TypesGen.ChatUsageLimitConfig) => void;
isUpdatingConfig: boolean;
updateConfigError: Error | null;
isUpdateConfigSuccess: boolean;
resetUpdateConfig: () => void;
onUpsertOverride: (args: {
userID: string;
req: TypesGen.UpsertChatUsageLimitOverrideRequest;
onSuccess: () => void;
}) => void;
isUpsertingOverride: boolean;
upsertOverrideError: Error | null;
onDeleteOverride: (userID: string) => void;
isDeletingOverride: boolean;
deleteOverrideError: Error | null;
onUpsertGroupOverride: (args: {
groupID: string;
req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest;
onSuccess: () => void;
}) => void;
isUpsertingGroupOverride: boolean;
upsertGroupOverrideError: Error | null;
onDeleteGroupOverride: (groupID: string) => void;
isDeletingGroupOverride: boolean;
deleteGroupOverrideError: Error | null;
// Usage data.
dateRange: DateRangeValue;
endDateIsExclusive: boolean;
onDateRangeChange: (value: DateRangeValue) => void;
searchFilter: string;
onSearchFilterChange: (value: string) => void;
usersQuery: PaginationResult & {
data: TypesGen.ChatCostUsersResponse | undefined;
isLoading: boolean;
isFetching: boolean;
error: unknown;
refetch: () => unknown;
};
drillInUserId: string | null;
drillInUser: TypesGen.User | null;
isDrillInUserLoading: boolean;
isDrillInUserError: boolean;
drillInUserError: unknown;
onDrillInUserRetry: () => void;
onClearSelectedUser: () => void;
onSelectUser: (user: TypesGen.ChatCostUserRollup) => void;
summaryData: TypesGen.ChatCostSummary | undefined;
isSummaryLoading: boolean;
summaryError: unknown;
onSummaryRetry: () => void;
}
// ── View component ──
export const AgentSettingsSpendPageView: FC<
AgentSettingsSpendPageViewProps
> = ({
configData,
isLoadingConfig,
configError,
refetchConfig,
groupsData,
isLoadingGroups,
groupsError,
onUpdateConfig,
isUpdatingConfig,
updateConfigError,
isUpdateConfigSuccess,
resetUpdateConfig,
onUpsertOverride,
isUpsertingOverride,
upsertOverrideError,
onDeleteOverride,
isDeletingOverride,
deleteOverrideError,
onUpsertGroupOverride,
isUpsertingGroupOverride,
upsertGroupOverrideError,
onDeleteGroupOverride,
isDeletingGroupOverride,
deleteGroupOverrideError,
dateRange,
endDateIsExclusive,
onDateRangeChange,
searchFilter,
onSearchFilterChange,
usersQuery,
drillInUserId,
drillInUser,
isDrillInUserLoading,
isDrillInUserError,
drillInUserError,
onDrillInUserRetry,
onClearSelectedUser,
onSelectUser,
summaryData,
isSummaryLoading,
summaryError,
onSummaryRetry,
}) => {
// ── Derived limit values ──
const defaultLimitValues: DefaultLimitFormValues = (() => {
const spendLimitMicros = configData?.spend_limit_micros;
const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined;
return {
enabled,
period: normalizeChatUsageLimitPeriod(configData?.period),
amountDollars: enabled
? microsToDollars(spendLimitMicros).toString()
: "",
};
})();
const defaultLimitKey = JSON.stringify({
spend_limit_micros: configData?.spend_limit_micros ?? null,
period: defaultLimitValues.period,
});
// ── Derived usage display state ──
const displayDateRange = toInclusiveDateRange(dateRange, endDateIsExclusive);
const dateRangeLabel = formatUsageDateRange(dateRange, {
endDateIsExclusive,
});
// ── Limits handlers ──
const handleResetUpdateConfig = () => {
if (!isUpdatingConfig) {
resetUpdateConfig();
}
};
const handleSaveDefault = ({
enabled,
period,
amountDollars,
}: DefaultLimitFormValues) => {
const spendLimitMicros = enabled ? dollarsToMicros(amountDollars) : null;
onUpdateConfig({
spend_limit_micros: spendLimitMicros,
period,
updated_at: new Date().toISOString(),
});
};
const groupOverrides = configData?.group_overrides ?? [];
const overrides = configData?.overrides ?? [];
const unpricedModelCount = configData?.unpriced_model_count ?? 0;
if (drillInUserId) {
return (
<SpendDrillInView
selectedUser={drillInUser}
isLoading={isDrillInUserLoading}
isError={isDrillInUserError}
error={drillInUserError}
onRetry={onDrillInUserRetry}
onBack={onClearSelectedUser}
displayDateRange={displayDateRange}
onDateRangeChange={onDateRangeChange}
dateRangeLabel={dateRangeLabel}
summaryData={summaryData}
isSummaryLoading={isSummaryLoading}
summaryError={summaryError}
onSummaryRetry={onSummaryRetry}
/>
);
}
// ── List mode ──
return (
<GroupOverrideController
groupOverrides={groupOverrides}
groups={groupsData ?? []}
isLoadingGroups={isLoadingGroups}
onUpsertGroupOverride={onUpsertGroupOverride}
>
{(groupCtrl) => (
<UserOverrideController
overrides={overrides}
onUpsertOverride={onUpsertOverride}
>
{(userCtrl) => (
<div className="space-y-10">
<SectionHeader
label="Spend management"
description="Configure spend limits and monitor usage across your deployment."
badge={<AdminBadge />}
/>
{isLoadingConfig ? (
<div className="flex items-center justify-center rounded-lg border border-border-default px-6 py-10">
<Spinner loading className="h-6 w-6" />
</div>
) : configError ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border-default px-6 py-10 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(
configError,
"Failed to load spend limit settings.",
)}
</p>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void refetchConfig()}
>
Retry
</Button>
</div>
) : (
<>
{/* Section 1: Default spend limit */}
<DefaultLimitController
key={defaultLimitKey}
initialValues={defaultLimitValues}
onSave={handleSaveDefault}
>
{({
enabled,
onEnabledChange,
period,
onPeriodChange,
amountDollars,
onAmountDollarsChange,
isAmountValid,
saveDefault,
}) => (
<section>
<SectionHeader
level="section"
label="Default spend limit"
description="Set a deployment-wide spend cap that applies to all users by default."
/>
<DefaultLimitSection
hideHeader
adminBadge={null}
enabled={enabled}
onEnabledChange={(v) => {
handleResetUpdateConfig();
onEnabledChange(v);
}}
period={period}
onPeriodChange={(v) => {
handleResetUpdateConfig();
onPeriodChange(v);
}}
amountDollars={amountDollars}
onAmountDollarsChange={(v) => {
handleResetUpdateConfig();
onAmountDollarsChange(v);
}}
unpricedModelCount={unpricedModelCount}
/>
<div className="flex items-center justify-end gap-3 pt-4">
<div className="min-h-4 text-xs">
{updateConfigError && (
<p className="m-0 text-content-destructive">
{getErrorMessage(
updateConfigError,
"Failed to save the default spend limit.",
)}
</p>
)}
{isUpdateConfigSuccess && (
<p className="m-0 text-content-success">Saved!</p>
)}
</div>
<Button
size="sm"
type="button"
onClick={saveDefault}
disabled={isUpdatingConfig || !isAmountValid}
>
{isUpdatingConfig ? (
<Spinner loading className="h-4 w-4" />
) : null}
Save default limit
</Button>
</div>
</section>
)}
</DefaultLimitController>
{/* Section 2: Group limits */}
<section>
<SectionHeader
level="section"
label="Group limits"
description="Override the default limit for specific groups. The lowest group limit applies."
/>
<GroupLimitsSection
hideHeader
groupOverrides={groupOverrides}
showGroupForm={groupCtrl.showGroupForm}
onShowGroupFormChange={
groupCtrl.handleShowGroupFormChange
}
selectedGroup={groupCtrl.selectedGroup}
onSelectedGroupChange={groupCtrl.setSelectedGroup}
groupAmount={groupCtrl.groupAmount}
onGroupAmountChange={groupCtrl.setGroupAmount}
availableGroups={groupCtrl.availableGroups}
groupAutocompleteNoOptionsText={
groupCtrl.groupAutocompleteNoOptionsText
}
groupsLoading={isLoadingGroups}
editingGroupOverride={groupCtrl.editingGroupOverride}
onEditGroupOverride={(override) => {
userCtrl.handleShowUserFormChange(false);
groupCtrl.handleEditGroupOverride(override);
}}
onAddGroupOverride={groupCtrl.handleAddGroupOverride}
onDeleteGroupOverride={onDeleteGroupOverride}
upsertPending={isUpsertingGroupOverride}
upsertError={upsertGroupOverrideError}
deletePending={isDeletingGroupOverride}
deleteError={deleteGroupOverrideError}
groupsError={groupsError}
/>
</section>
</>
)}
{/* Section 3: Per-user spend */}
<section>
<SectionHeader
level="section"
label="Per-user spend"
description="User overrides take highest priority, followed by group limits, then the default."
/>
<div className="flex items-center justify-between pb-4">
<span className="text-sm font-medium text-content-primary">
Date range
</span>
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
</div>
{!configError && !isLoadingConfig && (
<UserOverridesSection
hideHeader
overrides={overrides}
showUserForm={userCtrl.showUserForm}
onShowUserFormChange={userCtrl.handleShowUserFormChange}
selectedUser={userCtrl.selectedUserOverride}
onSelectedUserChange={userCtrl.setSelectedUserOverride}
userOverrideAmount={userCtrl.userOverrideAmount}
onUserOverrideAmountChange={userCtrl.setUserOverrideAmount}
selectedUserAlreadyOverridden={
userCtrl.editingUserOverride
? false
: userCtrl.selectedUserAlreadyOverridden
}
editingUserOverride={userCtrl.editingUserOverride}
onEditUserOverride={(override) => {
groupCtrl.handleShowGroupFormChange(false);
userCtrl.handleEditUserOverride(override);
}}
onAddOverride={userCtrl.handleAddOverride}
onDeleteOverride={onDeleteOverride}
upsertPending={isUpsertingOverride}
upsertError={upsertOverrideError}
deletePending={isDeletingOverride}
deleteError={deleteOverrideError}
/>
)}
{/* Search */}
<div className="pt-6">
<div className="w-full md:max-w-sm">
<SearchField
value={searchFilter}
onChange={onSearchFilterChange}
placeholder="Search by name or username"
aria-label="Search usage by name or username"
/>
</div>
</div>
{/* Loading state */}
{usersQuery.isLoading && (
<div
role="status"
aria-label="Loading usage"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner
size="lg"
loading
className="text-content-secondary"
/>
</div>
)}
{/* Error state */}
{usersQuery.error != null && (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(
usersQuery.error,
"Failed to load usage data.",
)}
</p>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void usersQuery.refetch()}
>
Retry
</Button>
</div>
)}
{/* User table + pagination */}
{usersQuery.data && (
<div className="relative pt-3">
{usersQuery.isFetching && !usersQuery.isLoading && (
<div
role="status"
aria-label="Refreshing usage"
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-primary/50"
>
<Spinner
size="lg"
loading
className="text-content-secondary"
/>
</div>
)}
{usersQuery.data.users.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
No usage data for this period.
</p>
) : (
<PaginationContainer
query={usersQuery}
paginationUnitLabel="users"
>
<div className="overflow-hidden rounded-lg border border-border-default">
<Table aria-label="Per-user spend">
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead className="text-right">
Cost
</TableHead>
<TableHead className="text-right">
Messages
</TableHead>
<TableHead className="text-right">
Chats
</TableHead>
<TableHead className="text-right">
Input
</TableHead>
<TableHead className="text-right">
Output
</TableHead>
<TableHead className="text-right">
Cache Read
</TableHead>
<TableHead className="text-right">
Cache Write
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.data.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={onSelectUser}
/>
))}
</TableBody>
</Table>
</div>
</PaginationContainer>
)}
</div>
)}
</section>
</div>
)}
</UserOverrideController>
)}
</GroupOverrideController>
);
};
@@ -1,147 +0,0 @@
import dayjs from "dayjs";
import { type FC, useState } from "react";
import { keepPreviousData, useQuery } from "react-query";
import { useSearchParams } from "react-router";
import { chatCostSummary, chatCostUsers } from "#/api/queries/chats";
import { user } from "#/api/queries/users";
import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker";
import { useDebouncedValue } from "#/hooks/debounce";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsUsagePageView } from "./AgentSettingsUsagePageView";
const pageSize = 10;
const usageStartDateSearchParam = "startDate";
const usageEndDateSearchParam = "endDate";
const getDefaultUsageDateRange = (now?: dayjs.Dayjs): DateRangeValue => {
const end = now ?? dayjs();
return {
startDate: end.subtract(30, "day").toDate(),
endDate: end.toDate(),
};
};
interface AgentSettingsUsagePageProps {
/** Override the current time for date range calculation. Used for
* deterministic Storybook snapshots. */
now?: dayjs.Dayjs;
}
const AgentSettingsUsagePage: FC<AgentSettingsUsagePageProps> = ({ now }) => {
const { permissions } = useAuthenticated();
const [searchParams, setSearchParams] = useSearchParams();
const [searchFilter, setSearchFilter] = useState("");
const debouncedSearch = useDebouncedValue(searchFilter, 300);
const [page, setPage] = useState(1);
const startDateParam =
searchParams.get(usageStartDateSearchParam)?.trim() ?? "";
const endDateParam = searchParams.get(usageEndDateSearchParam)?.trim() ?? "";
const [defaultDateRange] = useState(() => getDefaultUsageDateRange(now));
let dateRange = defaultDateRange;
let hasExplicitDateRange = false;
if (startDateParam && endDateParam) {
const parsedStartDate = new Date(startDateParam);
const parsedEndDate = new Date(endDateParam);
if (
!Number.isNaN(parsedStartDate.getTime()) &&
!Number.isNaN(parsedEndDate.getTime()) &&
parsedStartDate.getTime() <= parsedEndDate.getTime()
) {
dateRange = {
startDate: parsedStartDate,
endDate: parsedEndDate,
};
hasExplicitDateRange = true;
}
}
const dateRangeParams = {
start_date: dateRange.startDate.toISOString(),
end_date: dateRange.endDate.toISOString(),
};
const offset = (page - 1) * pageSize;
const onDateRangeChange = (value: DateRangeValue) => {
// Reset pagination but preserve user selection and other params.
setPage(1);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set(usageStartDateSearchParam, value.startDate.toISOString());
next.set(usageEndDateSearchParam, value.endDate.toISOString());
return next;
});
};
const usersQuery = useQuery({
...chatCostUsers({
...dateRangeParams,
username: debouncedSearch || undefined,
limit: pageSize,
offset,
}),
placeholderData: keepPreviousData,
});
const selectedUserId = searchParams.get("user");
const selectedUserQuery = useQuery({
...user(selectedUserId ?? ""),
enabled: selectedUserId !== null,
});
const summaryQuery = useQuery({
...chatCostSummary(selectedUserId ?? "me", dateRangeParams),
enabled: selectedUserId !== null,
});
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsUsagePageView
dateRange={dateRange}
hasExplicitDateRange={hasExplicitDateRange}
onDateRangeChange={onDateRangeChange}
searchFilter={searchFilter}
onSearchFilterChange={setSearchFilter}
page={page}
onPageChange={setPage}
pageSize={pageSize}
offset={offset}
usersData={usersQuery.data}
isUsersLoading={usersQuery.isLoading}
isUsersFetching={usersQuery.isFetching}
usersError={usersQuery.error}
onUsersRetry={() => void usersQuery.refetch()}
selectedUserId={selectedUserId}
selectedUser={selectedUserQuery.data ?? null}
isSelectedUserLoading={selectedUserQuery.isLoading}
isSelectedUserError={selectedUserQuery.isError}
selectedUserError={selectedUserQuery.error}
onSelectedUserRetry={() => void selectedUserQuery.refetch()}
onClearSelectedUser={() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("user");
return next;
});
}}
onSelectUser={(u) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("user", u.user_id);
return next;
});
}}
summaryData={summaryQuery.data}
isSummaryLoading={summaryQuery.isLoading}
summaryError={summaryQuery.error}
onSummaryRetry={() => void summaryQuery.refetch()}
/>
</RequirePermission>
);
};
export default AgentSettingsUsagePage;
@@ -1,252 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { AgentSettingsUsagePageView } from "./AgentSettingsUsagePageView";
const mockUsers: TypesGen.ChatCostUserRollup[] = [
{
user_id: "user-1",
username: "alice",
name: "Alice Liddell",
avatar_url: "",
total_cost_micros: 2_500_000,
message_count: 42,
chat_count: 5,
total_input_tokens: 200_000,
total_output_tokens: 300_000,
total_cache_read_tokens: 10_000,
total_cache_creation_tokens: 5_000,
total_runtime_ms: 0,
},
{
user_id: "user-2",
username: "bob",
name: "Bob Builder",
avatar_url: "",
total_cost_micros: 1_000_000,
message_count: 18,
chat_count: 3,
total_input_tokens: 80_000,
total_output_tokens: 120_000,
total_cache_read_tokens: 4_000,
total_cache_creation_tokens: 2_000,
total_runtime_ms: 0,
},
];
const mockUsersResponse: TypesGen.ChatCostUsersResponse = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: mockUsers.length,
users: mockUsers,
};
const mockUserProfile: TypesGen.User = {
id: "user-1",
username: "alice",
name: "Alice Liddell",
email: "alice@example.com",
avatar_url: "",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-06-01T00:00:00Z",
status: "active",
organization_ids: [],
roles: [],
last_seen_at: "2026-03-11T10:00:00Z",
login_type: "password",
has_ai_seat: false,
};
const mockCostSummary: TypesGen.ChatCostSummary = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 2_500_000,
priced_message_count: 40,
unpriced_message_count: 2,
total_input_tokens: 200_000,
total_output_tokens: 300_000,
total_cache_read_tokens: 10_000,
total_cache_creation_tokens: 5_000,
total_runtime_ms: 0,
by_model: [
{
model_config_id: "model-1",
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 2_000_000,
message_count: 30,
total_input_tokens: 150_000,
total_output_tokens: 250_000,
total_cache_read_tokens: 8_000,
total_cache_creation_tokens: 4_000,
total_runtime_ms: 0,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Refactor auth module",
total_cost_micros: 1_200_000,
message_count: 15,
total_input_tokens: 80_000,
total_output_tokens: 120_000,
total_cache_read_tokens: 3_000,
total_cache_creation_tokens: 1_500,
total_runtime_ms: 0,
},
],
};
const defaultDateRange = {
startDate: new Date("2026-02-10T00:00:00Z"),
endDate: new Date("2026-03-12T00:00:00Z"),
};
const baseProps = {
dateRange: defaultDateRange,
hasExplicitDateRange: false,
searchFilter: "",
page: 1,
pageSize: 25,
offset: 0,
isUsersLoading: false,
isUsersFetching: false,
usersError: undefined as unknown,
selectedUserId: null as string | null,
selectedUser: null as TypesGen.User | null,
isSelectedUserLoading: false,
isSelectedUserError: false,
selectedUserError: undefined as unknown,
summaryData: undefined as TypesGen.ChatCostSummary | undefined,
isSummaryLoading: false,
summaryError: undefined as unknown,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsUsagePageView",
component: AgentSettingsUsagePageView,
args: {
...baseProps,
onDateRangeChange: fn(),
onSearchFilterChange: fn(),
onPageChange: fn(),
onUsersRetry: fn(),
onSelectedUserRetry: fn(),
onClearSelectedUser: fn(),
onSelectUser: fn(),
onSummaryRetry: fn(),
},
} satisfies Meta<typeof AgentSettingsUsagePageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsUsagePageView>;
export const UsageUserList: Story = {
args: {
usersData: mockUsersResponse,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Usage");
await expect(await canvas.findByText("Alice Liddell")).toBeInTheDocument();
await expect(canvas.getByText("Bob Builder")).toBeInTheDocument();
await expect(
canvas.getByPlaceholderText("Search by name or username"),
).toBeInTheDocument();
},
};
export const UsageDateFilter: Story = {
args: {
usersData: mockUsersResponse,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Usage");
// The date range picker trigger should be visible.
const datePickerTrigger = await canvas.findByRole("button", {
name: /Feb.*Mar/,
});
expect(datePickerTrigger).toBeInTheDocument();
},
};
export const UsageDateFilterRefetchOverlay: Story = {
args: {
usersData: mockUsersResponse,
isUsersFetching: true,
isUsersLoading: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Table data should be visible behind the overlay.
await canvas.findByText("Alice Liddell");
// The refetch overlay spinner should be shown.
await expect(
await canvas.findByRole("status", { name: "Refreshing usage" }),
).toBeInTheDocument();
},
};
export const UsageEmpty: Story = {
args: {
usersData: {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 0,
users: [],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Usage");
await expect(
await canvas.findByText("No usage data for this period."),
).toBeInTheDocument();
},
};
export const UsageUserDrillIn: Story = {
args: {
selectedUserId: "user-1",
selectedUser: mockUserProfile,
summaryData: mockCostSummary,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Detail view shows user info.
await canvas.findByText(`User ID: ${mockUserProfile.id}`);
await expect(canvas.getByText("Alice Liddell")).toBeInTheDocument();
await expect(canvas.getByText("@alice")).toBeInTheDocument();
// The Back button should be visible.
await expect(canvas.getByText("Back")).toBeInTheDocument();
},
};
export const UsageUserDrillInAndBack: Story = {
args: {
selectedUserId: "user-1",
selectedUser: mockUserProfile,
summaryData: mockCostSummary,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText(`User ID: ${mockUserProfile.id}`);
// Click Back.
await userEvent.click(canvas.getByText("Back"));
// The onClearSelectedUser callback should have been called.
expect(args.onClearSelectedUser).toHaveBeenCalled();
},
};
@@ -1,398 +0,0 @@
import dayjs from "dayjs";
import type { FC } from "react";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { Button } from "#/components/Button/Button";
import {
DateRangePicker,
type DateRangeValue,
} from "#/components/DateRangePicker/DateRangePicker";
import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount";
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
import { SearchField } from "#/components/SearchField/SearchField";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
import { formatTokenCount } from "#/utils/analytics";
import { formatCostMicros } from "#/utils/currency";
import { AdminBadge } from "./components/AdminBadge";
import { BackButton } from "./components/BackButton";
import { ChatCostSummaryView } from "./components/ChatCostSummaryView";
import { SectionHeader } from "./components/SectionHeader";
const formatUsageDateRange = (
value: DateRangeValue,
options?: {
endDateIsExclusive?: boolean;
},
) => {
// Custom ranges keep the raw API end boundary, which can be midnight on
// the following day for full-day selections. Show the inclusive day in
// the drill-in label without changing the query params.
const displayEndDate =
options?.endDateIsExclusive &&
dayjs(value.endDate).isSame(dayjs(value.endDate).startOf("day"))
? dayjs(value.endDate).subtract(1, "day")
: dayjs(value.endDate);
return `${dayjs(value.startDate).format("MMM D")} – ${displayEndDate.format(
"MMM D, YYYY",
)}`;
};
const UserRow: FC<{
user: TypesGen.ChatCostUserRollup;
onSelect: (user: TypesGen.ChatCostUserRollup) => void;
}> = ({ user, onSelect }) => {
const clickableRowProps = useClickableTableRow({
onClick: () => onSelect(user),
});
return (
<TableRow
{...clickableRowProps}
aria-label={`View details for ${user.name || user.username}`}
>
<TableCell className="min-w-[220px] px-4 py-3">
<AvatarData
title={user.name || user.username}
subtitle={`@${user.username}`}
src={user.avatar_url}
imgFallbackText={user.username}
/>
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatCostMicros(user.total_cost_micros)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{user.message_count.toLocaleString()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{user.chat_count.toLocaleString()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(user.total_input_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(user.total_output_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(user.total_cache_read_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(user.total_cache_creation_tokens)}
</TableCell>
</TableRow>
);
};
interface AgentSettingsUsagePageViewProps {
// Raw date range (parsed by Page from URL params)
dateRange: DateRangeValue;
hasExplicitDateRange: boolean;
onDateRangeChange: (value: DateRangeValue) => void;
// Search & pagination (state owned by Page, needed for queries)
searchFilter: string;
onSearchFilterChange: (value: string) => void;
page: number;
onPageChange: (page: number) => void;
pageSize: number;
offset: number;
// User list query
usersData: TypesGen.ChatCostUsersResponse | undefined;
isUsersLoading: boolean;
isUsersFetching: boolean;
usersError: unknown;
onUsersRetry: () => void;
// Selected user drill-in
selectedUserId: string | null;
selectedUser: TypesGen.User | null;
isSelectedUserLoading: boolean;
isSelectedUserError: boolean;
selectedUserError: unknown;
onSelectedUserRetry: () => void;
onClearSelectedUser: () => void;
onSelectUser: (user: TypesGen.ChatCostUserRollup) => void;
// Cost summary for selected user
summaryData: TypesGen.ChatCostSummary | undefined;
isSummaryLoading: boolean;
summaryError: unknown;
onSummaryRetry: () => void;
}
export const AgentSettingsUsagePageView: FC<
AgentSettingsUsagePageViewProps
> = ({
dateRange,
hasExplicitDateRange,
onDateRangeChange,
searchFilter,
onSearchFilterChange,
page,
onPageChange,
pageSize,
offset,
usersData,
isUsersLoading,
isUsersFetching,
usersError,
onUsersRetry,
selectedUserId,
selectedUser,
isSelectedUserLoading,
isSelectedUserError,
selectedUserError,
onSelectedUserRetry,
onClearSelectedUser,
onSelectUser,
summaryData,
isSummaryLoading,
summaryError,
onSummaryRetry,
}) => {
// ── Derived display state ──
const { endDate } = dateRange;
const isExclusiveMidnightEnd =
hasExplicitDateRange &&
endDate.getHours() === 0 &&
endDate.getMinutes() === 0 &&
endDate.getSeconds() === 0 &&
endDate.getMilliseconds() === 0;
const displayDateRange = isExclusiveMidnightEnd
? {
startDate: dateRange.startDate,
endDate: new Date(endDate.getTime() - 1),
}
: dateRange;
const dateRangeLabel = formatUsageDateRange(dateRange, {
endDateIsExclusive: hasExplicitDateRange,
});
const totalCount = usersData?.count ?? 0;
const hasPreviousPage = page > 1;
const hasNextPage = offset + pageSize < totalCount;
const header = (
<SectionHeader
label="Usage"
description={
selectedUserId
? "Review deployment Coder Agents usage for a specific user."
: "Review deployment Coder Agents usage and drill into individual users."
}
badge={<AdminBadge />}
action={
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
}
/>
);
if (selectedUserId) {
const backButton = <BackButton onClick={onClearSelectedUser} />;
if (isSelectedUserLoading) {
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex min-h-[240px] items-center justify-center">
<Spinner size="lg" loading className="text-content-secondary" />
</div>
</div>
);
}
if (isSelectedUserError || !selectedUser) {
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(
selectedUserError,
"Failed to load user profile.",
)}
</p>{" "}
<Button
variant="outline"
size="sm"
type="button"
onClick={onSelectedUserRetry}
>
Retry
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border-default bg-surface-secondary px-4 py-3">
<AvatarData
title={selectedUser.name || selectedUser.username}
subtitle={`@${selectedUser.username}`}
src={selectedUser.avatar_url}
imgFallbackText={selectedUser.username}
/>
<div className="min-w-0 text-xs text-content-secondary">
<div>User ID: {selectedUser.id}</div>
<div>{dateRangeLabel}</div>
</div>
</div>
<ChatCostSummaryView
summary={summaryData}
isLoading={isSummaryLoading}
error={summaryError}
onRetry={onSummaryRetry}
loadingLabel="Loading usage details"
emptyMessage="No usage data for this user in the selected period."
/>
</div>
);
}
return (
<div className="space-y-6">
{header}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="w-full md:max-w-sm">
<SearchField
value={searchFilter}
onChange={(value) => {
onSearchFilterChange(value);
onPageChange(1);
}}
placeholder="Search by name or username"
aria-label="Search usage by name or username"
/>
</div>
{usersData && (
<PaginationAmount
limit={pageSize}
totalRecords={usersData.count}
currentOffsetStart={usersData.count === 0 ? 0 : offset + 1}
paginationUnitLabel="users"
/>
)}
</div>
{isUsersLoading && (
<div
role="status"
aria-label="Loading usage"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersError != null && (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(usersError, "Failed to load usage data.")}
</p>{" "}
<Button
variant="outline"
size="sm"
type="button"
onClick={onUsersRetry}
>
Retry
</Button>
</div>
)}
{usersData && (
<div className="relative">
{isUsersFetching && !isUsersLoading && (
<div
role="status"
aria-label="Refreshing usage"
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-primary/50"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersData.users.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
No usage data for this period.
</p>
) : (
<>
<div className="overflow-hidden rounded-lg border border-border-default">
<Table>
<TableHeader>
<TableRow className="text-left text-xs uppercase tracking-wide text-content-secondary">
<TableHead className="px-4 py-3">User</TableHead>
<TableHead className="px-4 py-3 text-right">
Total Cost
</TableHead>
<TableHead className="px-4 py-3 text-right">
Messages
</TableHead>
<TableHead className="px-4 py-3 text-right">
Chats
</TableHead>
<TableHead className="px-4 py-3 text-right">
Input Tokens
</TableHead>
<TableHead className="px-4 py-3 text-right">
Output Tokens
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Read
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Write
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersData.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={onSelectUser}
/>
))}
</TableBody>
</Table>
</div>
<PaginationWidgetBase
totalRecords={usersData.count}
currentPage={page}
pageSize={pageSize}
onPageChange={onPageChange}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
/>
</>
)}
</div>
)}
</div>
);
};
@@ -30,7 +30,7 @@ import AgentAnalyticsPage from "./AgentAnalyticsPage";
import AgentCreatePage from "./AgentCreatePage";
import { AgentSettingsBehaviorPageView } from "./AgentSettingsBehaviorPageView";
import AgentSettingsPage from "./AgentSettingsPage";
import { AgentSettingsUsagePageView } from "./AgentSettingsUsagePageView";
import AgentSettingsSpendPage from "./AgentSettingsSpendPage";
import { AgentsPageView } from "./AgentsPageView";
import type { ModelSelectorOption } from "./components/ChatElements";
@@ -194,40 +194,6 @@ const BehaviorRouteElement = () => {
);
};
const UsageRouteElement = () => (
<AgentSettingsUsagePageView
dateRange={{
startDate: new Date("2026-02-10"),
endDate: new Date("2026-03-12"),
}}
hasExplicitDateRange={false}
onDateRangeChange={fn()}
searchFilter=""
onSearchFilterChange={fn()}
page={1}
onPageChange={fn()}
pageSize={25}
offset={0}
usersData={mockUsageUsers}
isUsersLoading={false}
isUsersFetching={false}
usersError={null}
onUsersRetry={fn()}
selectedUserId={null}
selectedUser={null}
isSelectedUserLoading={false}
isSelectedUserError={false}
selectedUserError={null}
onSelectedUserRetry={fn()}
onClearSelectedUser={fn()}
onSelectUser={fn()}
summaryData={undefined}
isSummaryLoading={false}
summaryError={null}
onSummaryRetry={fn()}
/>
);
const agentsRouting = {
path: "/agents",
useStoryElement: true,
@@ -238,7 +204,11 @@ const agentsRouting = {
children: [
{ index: true, element: <Navigate to="behavior" replace /> },
{ path: "behavior", element: <BehaviorRouteElement /> },
{ path: "usage", element: <UsageRouteElement /> },
{ path: "spend", element: <AgentSettingsSpendPage now={fixedNow} /> },
{
path: "usage",
element: <Navigate to="/agents/settings/spend" replace />,
},
],
},
{ path: "analytics", element: <AgentAnalyticsPage now={fixedNow} /> },
@@ -358,6 +328,21 @@ const meta: Meta<typeof AgentsPageView> = {
workspace_ttl_ms: 0,
});
spyOn(API.experimental, "updateChatWorkspaceTTL").mockResolvedValue();
spyOn(API.experimental, "getChatUsageLimitConfig").mockResolvedValue({
spend_limit_micros: null,
period: "month",
updated_at: "2026-02-18T00:00:00.000Z",
unpriced_model_count: 0,
overrides: [],
group_overrides: [],
});
spyOn(API, "getGroups").mockResolvedValue([]);
spyOn(API.experimental, "getChatCostUsers").mockResolvedValue({
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 0,
users: [],
});
},
};
@@ -703,12 +688,12 @@ export const SettingsViewResets: Story = {
).toBeInTheDocument();
});
// Navigate to Usage section
await userEvent.click(screen.getByText("Usage"));
// Navigate to Spend section
await userEvent.click(screen.getByText("Spend"));
await waitFor(() => {
expect(
screen.getByText(
"Review deployment Coder Agents usage and drill into individual users.",
"Configure spend limits and monitor usage across your deployment.",
),
).toBeInTheDocument();
});
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
@@ -114,3 +114,71 @@ export const UnpricedWarning: Story = {
}),
},
};
const manyModels = Array.from({ length: 12 }, (_, i) => ({
model_config_id: `model-${i + 1}`,
display_name: `Model ${i + 1}`,
provider: "TestProvider",
model: `test-model-${i + 1}`,
total_cost_micros: 100_000 * (i + 1),
message_count: i + 1,
total_input_tokens: 10_000 * (i + 1),
total_output_tokens: 20_000 * (i + 1),
total_cache_read_tokens: 1_000,
total_cache_creation_tokens: 500,
total_runtime_ms: 0,
}));
const manyChats = Array.from({ length: 12 }, (_, i) => ({
root_chat_id: `chat-${i + 1}`,
chat_title: `Agent ${i + 1}`,
total_cost_micros: 50_000 * (i + 1),
message_count: i + 1,
total_input_tokens: 5_000 * (i + 1),
total_output_tokens: 10_000 * (i + 1),
total_cache_read_tokens: 500,
total_cache_creation_tokens: 250,
total_runtime_ms: 0,
}));
export const PaginatedChats: Story = {
args: {
summary: buildSummary({ by_chat: manyChats }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// First page shows agents 1–10, agent 11 is on page 2.
await canvas.findByText("Agent 1");
await expect(canvas.queryByText("Agent 11")).not.toBeInTheDocument();
// Navigate to page 2 (second pagination widget on the page).
const nextButtons = canvas.getAllByRole("button", { name: /next/i });
await userEvent.click(nextButtons[nextButtons.length - 1]);
await expect(canvas.getByText("Agent 11")).toBeInTheDocument();
await expect(canvas.getByText("Agent 12")).toBeInTheDocument();
await expect(canvas.queryByText("Agent 1")).not.toBeInTheDocument();
},
};
export const PaginatedModels: Story = {
args: {
summary: buildSummary({ by_model: manyModels }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// First page shows models 1–10, model 11 is on page 2.
await canvas.findByText("Model 1");
await expect(canvas.queryByText("Model 11")).not.toBeInTheDocument();
// Navigate to page 2.
const nextButton = canvas.getByRole("button", { name: /next/i });
await userEvent.click(nextButton);
await expect(canvas.getByText("Model 11")).toBeInTheDocument();
await expect(canvas.getByText("Model 12")).toBeInTheDocument();
await expect(canvas.queryByText("Model 1")).not.toBeInTheDocument();
},
};
@@ -1,9 +1,10 @@
import dayjs from "dayjs";
import { InfoIcon, TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
import { type FC, useState } from "react";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
@@ -13,6 +14,11 @@ import {
TableHeader,
TableRow,
} from "#/components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { formatTokenCount } from "#/utils/analytics";
import { formatCostMicros } from "#/utils/currency";
@@ -52,6 +58,13 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({
loadingLabel,
emptyMessage,
}) => {
// Page state is intentionally not reset when summary data changes.
// The clamped derivation below guarantees the displayed page is
// always valid, and preserving the raw state lets the user return
// to their previous page if they widen the date range back.
const [modelPage, setModelPage] = useState(1);
const [chatPage, setChatPage] = useState(1);
if (isLoading) {
return (
<div
@@ -81,6 +94,27 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({
return null;
}
const modelPageSize = 10;
const modelMaxPage = Math.max(
1,
Math.ceil(summary.by_model.length / modelPageSize),
);
const clampedModelPage = Math.min(modelPage, modelMaxPage);
const pagedModels = summary.by_model.slice(
(clampedModelPage - 1) * modelPageSize,
clampedModelPage * modelPageSize,
);
const chatPageSize = 10;
const chatMaxPage = Math.max(
1,
Math.ceil(summary.by_chat.length / chatPageSize),
);
const clampedChatPage = Math.min(chatPage, chatMaxPage);
const pagedChats = summary.by_chat.slice(
(clampedChatPage - 1) * chatPageSize,
clampedChatPage * chatPageSize,
);
const usageLimit = summary.usage_limit;
const showUsageLimitCard = usageLimit?.is_limited === true;
const usageLimitCurrentSpend = usageLimit?.current_spend ?? 0;
@@ -249,119 +283,133 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({
</p>
) : (
<>
<div className="overflow-x-auto rounded-lg border border-border-default">
<Table className="text-sm" aria-label="Cost breakdown by model">
<div>
<Table aria-label="Cost breakdown by model">
<TableHeader>
<TableRow className="text-left text-xs font-medium uppercase tracking-wide text-content-secondary">
<TableHead className="px-4 py-3">Model</TableHead>
<TableHead className="px-4 py-3">Provider</TableHead>
<TableHead className="px-4 py-3 text-right">Cost</TableHead>
<TableHead className="px-4 py-3 text-right">
Messages
</TableHead>
<TableHead className="px-4 py-3 text-right">Input</TableHead>
<TableHead className="px-4 py-3 text-right">Output</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Read
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Write
</TableHead>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Messages</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
<TableHead className="text-right">Cache Read</TableHead>
<TableHead className="text-right">Cache Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.by_model.map((model) => (
<TableRow
key={model.model_config_id}
className="border-t border-border-default"
>
<TableCell className="px-4 py-3">
{model.display_name || model.model}
</TableCell>
<TableCell className="px-4 py-3 text-content-secondary">
{pagedModels.map((model) => (
<TableRow key={model.model_config_id}>
<TableCell>{model.display_name || model.model}</TableCell>
<TableCell className="text-content-secondary">
{model.provider}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatCostMicros(model.total_cost_micros)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{model.message_count.toLocaleString()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatTokenCount(model.total_input_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatTokenCount(model.total_output_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatTokenCount(model.total_cache_read_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatTokenCount(model.total_cache_creation_tokens)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{summary.by_model.length > modelPageSize && (
<div className="pt-4">
<PaginationWidgetBase
totalRecords={summary.by_model.length}
currentPage={clampedModelPage}
pageSize={modelPageSize}
onPageChange={setModelPage}
hasPreviousPage={clampedModelPage > 1}
hasNextPage={
clampedModelPage * modelPageSize < summary.by_model.length
}
/>
</div>
)}
</div>
<div className="overflow-x-auto rounded-lg border border-border-default">
<Table
className="text-sm"
aria-label="Cost breakdown by conversation"
>
<div>
<Table aria-label="Cost breakdown by agent">
<TableHeader>
<TableRow className="text-left text-xs font-medium uppercase tracking-wide text-content-secondary">
<TableHead className="px-4 py-3">Conversation</TableHead>
<TableHead className="px-4 py-3 text-right">Cost</TableHead>
<TableHead className="px-4 py-3 text-right">
Messages
</TableHead>
<TableHead className="px-4 py-3 text-right">Input</TableHead>
<TableHead className="px-4 py-3 text-right">Output</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Read
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Write
</TableHead>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Messages</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
<TableHead className="text-right">Cache Read</TableHead>
<TableHead className="text-right">Cache Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.by_chat.map((chat) => (
<TableRow
key={chat.root_chat_id}
className="border-t border-border-default"
>
<TableCell className="px-4 py-3">
{chat.chat_title || (
<span className="italic text-content-secondary">
Untitled conversation
{pagedChats.map((chat) => (
<TableRow key={chat.root_chat_id}>
<TableCell className="max-w-[200px]">
{chat.chat_title ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate">
{chat.chat_title}
</span>
</TooltipTrigger>
<TooltipContent>{chat.chat_title}</TooltipContent>
</Tooltip>
) : (
<span className="text-content-secondary">
Untitled agent
</span>
)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatCostMicros(chat.total_cost_micros)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{chat.message_count.toLocaleString()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatTokenCount(chat.total_input_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatTokenCount(chat.total_output_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatTokenCount(chat.total_cache_read_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<TableCell className="text-right tabular-nums">
{formatTokenCount(chat.total_cache_creation_tokens)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{summary.by_chat.length > chatPageSize && (
<div className="pt-4">
<PaginationWidgetBase
totalRecords={summary.by_chat.length}
currentPage={clampedChatPage}
pageSize={chatPageSize}
onPageChange={setChatPage}
hasPreviousPage={clampedChatPage > 1}
hasNextPage={
clampedChatPage * chatPageSize < summary.by_chat.length
}
/>
</div>
)}
</div>
</>
)}
@@ -0,0 +1,59 @@
import { type FC, type ReactNode, useState } from "react";
import type { ChatUsageLimitPeriod } from "#/api/typesGenerated";
import { isPositiveFiniteDollarAmount } from "#/utils/currency";
export interface DefaultLimitFormValues {
enabled: boolean;
period: ChatUsageLimitPeriod;
amountDollars: string;
}
interface DefaultLimitControllerProps {
initialValues: DefaultLimitFormValues;
onSave: (values: DefaultLimitFormValues) => void;
children: (props: {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
period: ChatUsageLimitPeriod;
onPeriodChange: (period: ChatUsageLimitPeriod) => void;
amountDollars: string;
onAmountDollarsChange: (amount: string) => void;
isAmountValid: boolean;
saveDefault: () => void;
}) => ReactNode;
}
export const DefaultLimitController: FC<DefaultLimitControllerProps> = ({
initialValues,
onSave,
children,
}) => {
const [enabled, setEnabled] = useState(initialValues.enabled);
const [period, setPeriod] = useState<ChatUsageLimitPeriod>(
initialValues.period,
);
const [amountDollars, setAmountDollars] = useState(
initialValues.amountDollars,
);
const isAmountValid = !enabled || isPositiveFiniteDollarAmount(amountDollars);
const handleSave = () => {
if (enabled && !isPositiveFiniteDollarAmount(amountDollars)) {
return;
}
onSave({ enabled, period, amountDollars });
};
return children({
enabled,
onEnabledChange: setEnabled,
period,
onPeriodChange: setPeriod,
amountDollars,
onAmountDollarsChange: setAmountDollars,
isAmountValid,
saveDefault: handleSave,
});
};
@@ -28,6 +28,7 @@ interface DefaultLimitSectionProps {
onAmountDollarsChange: (amount: string) => void;
unpricedModelCount: number;
adminBadge: ReactNode;
hideHeader?: boolean;
}
export const DefaultLimitSection: FC<DefaultLimitSectionProps> = ({
@@ -39,17 +40,20 @@ export const DefaultLimitSection: FC<DefaultLimitSectionProps> = ({
onAmountDollarsChange,
unpricedModelCount,
adminBadge,
hideHeader,
}) => {
const periodId = useId();
const amountId = useId();
return (
<section className="space-y-4">
<SectionHeader
label="Default Spend Limit"
description="Set a deployment-wide spend cap that applies to all users by default."
badge={adminBadge}
/>
{!hideHeader && (
<SectionHeader
label="Default Spend Limit"
description="Set a deployment-wide spend cap that applies to all users by default."
badge={adminBadge}
/>
)}
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
@@ -25,6 +25,7 @@ import { ConfirmDeleteDialog } from "../ConfirmDeleteDialog";
import { SectionHeader } from "../SectionHeader";
interface GroupLimitsSectionProps {
hideHeader?: boolean;
groupOverrides: ReadonlyArray<{
group_id: string;
group_display_name: string;
@@ -62,6 +63,7 @@ interface GroupLimitsSectionProps {
}
export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
hideHeader,
groupOverrides,
showGroupForm,
onShowGroupFormChange,
@@ -91,10 +93,12 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
return (
<section className="space-y-4">
<SectionHeader
label="Group Limits"
description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies."
/>
{!hideHeader && (
<SectionHeader
label="Group Limits"
description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies."
/>
)}
<div className="space-y-4">
{groupOverrides.length > 0 ? (
<Table>
@@ -0,0 +1,141 @@
import { type FC, type ReactNode, useState } from "react";
import type {
Group,
UpsertChatUsageLimitGroupOverrideRequest,
} from "#/api/typesGenerated";
import {
dollarsToMicros,
isPositiveFiniteDollarAmount,
microsToDollars,
} from "#/utils/currency";
interface EditingGroupOverride {
group_id: string;
group_display_name: string;
group_name: string;
group_avatar_url: string;
member_count: number;
}
type GroupOverrideChildProps = {
showGroupForm: boolean;
setShowGroupForm: (show: boolean) => void;
selectedGroup: Group | null;
setSelectedGroup: (group: Group | null) => void;
groupAmount: string;
setGroupAmount: (amount: string) => void;
editingGroupOverride: EditingGroupOverride | null;
setEditingGroupOverride: (override: EditingGroupOverride | null) => void;
handleShowGroupFormChange: (show: boolean) => void;
handleEditGroupOverride: (
override: EditingGroupOverride & {
spend_limit_micros: number | null;
},
) => void;
handleAddGroupOverride: () => void;
existingGroupIds: Set<string>;
availableGroups: Group[];
groupAutocompleteNoOptionsText: string;
};
interface GroupOverrideControllerProps {
groupOverrides: ReadonlyArray<{ group_id: string }>;
groups: ReadonlyArray<Group>;
isLoadingGroups: boolean;
onUpsertGroupOverride: (args: {
groupID: string;
req: UpsertChatUsageLimitGroupOverrideRequest;
onSuccess: () => void;
}) => void;
children: (props: GroupOverrideChildProps) => ReactNode;
}
export const GroupOverrideController: FC<GroupOverrideControllerProps> = ({
groupOverrides,
groups,
isLoadingGroups,
onUpsertGroupOverride,
children,
}) => {
const [showGroupForm, setShowGroupForm] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [groupAmount, setGroupAmount] = useState("");
const [editingGroupOverride, setEditingGroupOverride] =
useState<EditingGroupOverride | null>(null);
// Derived values.
const existingGroupIds = new Set(groupOverrides.map((g) => g.group_id));
const availableGroups = groups.filter((g) => !existingGroupIds.has(g.id));
const groupAutocompleteNoOptionsText = isLoadingGroups
? "Loading groups..."
: groups.length === 0
? "No groups configured"
: availableGroups.length === 0
? "All groups already have overrides"
: "No groups available";
// Handlers.
const handleShowGroupFormChange = (show: boolean) => {
setShowGroupForm(show);
if (!show) {
setEditingGroupOverride(null);
}
};
const handleEditGroupOverride = (
override: EditingGroupOverride & {
spend_limit_micros: number | null;
},
) => {
setEditingGroupOverride({
group_id: override.group_id,
group_display_name: override.group_display_name,
group_name: override.group_name,
group_avatar_url: override.group_avatar_url,
member_count: override.member_count,
});
setSelectedGroup(null);
setGroupAmount(
override.spend_limit_micros !== null
? microsToDollars(override.spend_limit_micros).toString()
: "",
);
setShowGroupForm(true);
};
const handleAddGroupOverride = () => {
const targetGroupID = editingGroupOverride?.group_id ?? selectedGroup?.id;
if (!targetGroupID || !isPositiveFiniteDollarAmount(groupAmount)) {
return;
}
onUpsertGroupOverride({
groupID: targetGroupID,
req: { spend_limit_micros: dollarsToMicros(groupAmount) },
onSuccess: () => {
setEditingGroupOverride(null);
setSelectedGroup(null);
setGroupAmount("");
setShowGroupForm(false);
},
});
};
return children({
showGroupForm,
setShowGroupForm,
selectedGroup,
setSelectedGroup,
groupAmount,
setGroupAmount,
editingGroupOverride,
setEditingGroupOverride,
handleShowGroupFormChange,
handleEditGroupOverride,
handleAddGroupOverride,
existingGroupIds,
availableGroups,
groupAutocompleteNoOptionsText,
});
};
@@ -1,524 +0,0 @@
import { ShieldIcon } from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import { getErrorMessage } from "#/api/errors";
import type {
ChatUsageLimitConfigResponse,
ChatUsageLimitPeriod,
Group,
UpsertChatUsageLimitGroupOverrideRequest,
UpsertChatUsageLimitOverrideRequest,
User,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import {
dollarsToMicros,
isPositiveFiniteDollarAmount,
microsToDollars,
} from "#/utils/currency";
import { DefaultLimitSection } from "./DefaultLimitSection";
import { GroupLimitsSection } from "./GroupLimitsSection";
import { normalizeChatUsageLimitPeriod } from "./limitsFormLogic";
import { UserOverridesSection } from "./UserOverridesSection";
interface DefaultLimitFormValues {
enabled: boolean;
period: ChatUsageLimitPeriod;
amountDollars: string;
}
interface DefaultLimitControllerProps {
initialValues: DefaultLimitFormValues;
onSave: (values: DefaultLimitFormValues) => Promise<void>;
children: (props: {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
period: ChatUsageLimitPeriod;
onPeriodChange: (period: ChatUsageLimitPeriod) => void;
amountDollars: string;
onAmountDollarsChange: (amount: string) => void;
isAmountValid: boolean;
saveDefault: () => Promise<void>;
}) => ReactNode;
}
const AdminBadge: FC = () => (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-default items-center gap-1 rounded bg-surface-tertiary/60 px-1.5 py-px text-[11px] font-medium text-content-secondary">
<ShieldIcon className="h-3 w-3" />
Admin
</span>
</TooltipTrigger>
<TooltipContent side="right">
Only visible to deployment administrators.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const DefaultLimitController: FC<DefaultLimitControllerProps> = ({
initialValues,
onSave,
children,
}) => {
const [enabled, setEnabled] = useState(initialValues.enabled);
const [period, setPeriod] = useState<ChatUsageLimitPeriod>(
initialValues.period,
);
const [amountDollars, setAmountDollars] = useState(
initialValues.amountDollars,
);
const isAmountValid = !enabled || isPositiveFiniteDollarAmount(amountDollars);
const handleSave = async () => {
if (enabled && !isPositiveFiniteDollarAmount(amountDollars)) {
return;
}
await onSave({ enabled, period, amountDollars });
};
return children({
enabled,
onEnabledChange: setEnabled,
period,
onPeriodChange: setPeriod,
amountDollars,
onAmountDollarsChange: setAmountDollars,
isAmountValid,
saveDefault: handleSave,
});
};
interface LimitsTabProps {
// Config query data.
configData: ChatUsageLimitConfigResponse | undefined;
isLoadingConfig: boolean;
configError: Error | null;
refetchConfig: () => void;
// Groups query data.
groupsData: Group[] | undefined;
isLoadingGroups: boolean;
groupsError: Error | null;
// Update config mutation.
onUpdateConfig: (
req: import("#/api/typesGenerated").ChatUsageLimitConfig,
) => Promise<unknown>;
isUpdatingConfig: boolean;
updateConfigError: Error | null;
isUpdateConfigSuccess: boolean;
resetUpdateConfig: () => void;
// Upsert user override mutation.
onUpsertOverride: (args: {
userID: string;
req: UpsertChatUsageLimitOverrideRequest;
}) => Promise<unknown>;
isUpsertingOverride: boolean;
upsertOverrideError: Error | null;
// Delete user override mutation.
onDeleteOverride: (userID: string) => Promise<unknown>;
isDeletingOverride: boolean;
deleteOverrideError: Error | null;
// Upsert group override mutation.
onUpsertGroupOverride: (args: {
groupID: string;
req: UpsertChatUsageLimitGroupOverrideRequest;
}) => Promise<unknown>;
isUpsertingGroupOverride: boolean;
upsertGroupOverrideError: Error | null;
// Delete group override mutation.
onDeleteGroupOverride: (groupID: string) => Promise<unknown>;
isDeletingGroupOverride: boolean;
deleteGroupOverrideError: Error | null;
}
export const LimitsTab: FC<LimitsTabProps> = ({
configData,
isLoadingConfig,
configError,
refetchConfig,
groupsData,
isLoadingGroups,
groupsError,
onUpdateConfig,
isUpdatingConfig,
updateConfigError,
isUpdateConfigSuccess,
resetUpdateConfig,
onUpsertOverride,
isUpsertingOverride,
upsertOverrideError,
onDeleteOverride,
isDeletingOverride,
deleteOverrideError,
onUpsertGroupOverride,
isUpsertingGroupOverride,
upsertGroupOverrideError,
onDeleteGroupOverride,
isDeletingGroupOverride,
deleteGroupOverrideError,
}) => {
const [showGroupForm, setShowGroupForm] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [groupAmount, setGroupAmount] = useState("");
const [showUserForm, setShowUserForm] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userOverrideAmount, setUserOverrideAmount] = useState("");
const [editingUserOverride, setEditingUserOverride] = useState<{
user_id: string;
name: string;
username: string;
avatar_url: string;
} | null>(null);
const [editingGroupOverride, setEditingGroupOverride] = useState<{
group_id: string;
group_display_name: string;
group_name: string;
group_avatar_url: string;
member_count: number;
} | null>(null);
const defaultLimitValues: DefaultLimitFormValues = (() => {
const spendLimitMicros = configData?.spend_limit_micros;
const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined;
return {
enabled,
period: normalizeChatUsageLimitPeriod(configData?.period),
amountDollars:
enabled && spendLimitMicros !== null && spendLimitMicros !== undefined
? microsToDollars(spendLimitMicros).toString()
: "",
};
})();
const defaultLimitKey = JSON.stringify({
spend_limit_micros: configData?.spend_limit_micros ?? null,
period: defaultLimitValues.period,
});
const existingGroupIds = new Set(
(configData?.group_overrides ?? []).map((g) => g.group_id),
);
const existingUserIds = new Set(
(configData?.overrides ?? []).map((o) => o.user_id),
);
const availableGroups = (groupsData ?? []).filter(
(g) => !existingGroupIds.has(g.id),
);
const selectedUserAlreadyOverridden = selectedUser
? existingUserIds.has(selectedUser.id)
: false;
const groupAutocompleteNoOptionsText = isLoadingGroups
? "Loading groups..."
: (groupsData?.length ?? 0) === 0
? "No groups configured"
: availableGroups.length === 0
? "All groups already have overrides"
: "No groups available";
const handleResetUpdateConfig = () => {
if (!isUpdatingConfig) {
resetUpdateConfig();
}
};
const handleShowUserFormChange = (show: boolean) => {
setShowUserForm(show);
if (!show) {
setEditingUserOverride(null);
}
};
const handleShowGroupFormChange = (show: boolean) => {
setShowGroupForm(show);
if (!show) {
setEditingGroupOverride(null);
}
};
const handleEditUserOverride = (override: {
user_id: string;
name: string;
username: string;
avatar_url: string;
spend_limit_micros: number | null;
}) => {
setShowGroupForm(false);
setEditingGroupOverride(null);
setEditingUserOverride({
user_id: override.user_id,
name: override.name,
username: override.username,
avatar_url: override.avatar_url,
});
setSelectedUser(null);
setUserOverrideAmount(
override.spend_limit_micros !== null
? microsToDollars(override.spend_limit_micros).toString()
: "",
);
setShowUserForm(true);
};
const handleEditGroupOverride = (override: {
group_id: string;
group_display_name: string;
group_name: string;
group_avatar_url: string;
member_count: number;
spend_limit_micros: number | null;
}) => {
setShowUserForm(false);
setEditingUserOverride(null);
setEditingGroupOverride({
group_id: override.group_id,
group_display_name: override.group_display_name,
group_name: override.group_name,
group_avatar_url: override.group_avatar_url,
member_count: override.member_count,
});
setSelectedGroup(null);
setGroupAmount(
override.spend_limit_micros !== null
? microsToDollars(override.spend_limit_micros).toString()
: "",
);
setShowGroupForm(true);
};
const handleSaveDefault = async ({
enabled,
period,
amountDollars,
}: DefaultLimitFormValues) => {
const spendLimitMicros = enabled ? dollarsToMicros(amountDollars) : null;
try {
await onUpdateConfig({
spend_limit_micros: spendLimitMicros,
period,
updated_at: new Date().toISOString(),
});
} catch {
// Keep the current form state so the inline mutation error is visible.
}
};
const handleAddOverride = async () => {
const targetUserID = editingUserOverride?.user_id ?? selectedUser?.id;
if (!targetUserID || !isPositiveFiniteDollarAmount(userOverrideAmount)) {
return;
}
try {
await onUpsertOverride({
userID: targetUserID,
req: { spend_limit_micros: dollarsToMicros(userOverrideAmount) },
});
setEditingUserOverride(null);
setSelectedUser(null);
setUserOverrideAmount("");
setShowUserForm(false);
} catch {
// Keep the current form state so the inline mutation error is visible.
}
};
const handleAddGroupOverride = async () => {
const targetGroupID = editingGroupOverride?.group_id ?? selectedGroup?.id;
if (!targetGroupID || !isPositiveFiniteDollarAmount(groupAmount)) {
return;
}
try {
await onUpsertGroupOverride({
groupID: targetGroupID,
req: { spend_limit_micros: dollarsToMicros(groupAmount) },
});
setEditingGroupOverride(null);
setSelectedGroup(null);
setGroupAmount("");
setShowGroupForm(false);
} catch {
// Keep the current form state so the inline mutation error is visible.
}
};
const handleDeleteGroupOverride = async (groupID: string) => {
try {
await onDeleteGroupOverride(groupID);
} catch {
// Keep the current UI state so the inline mutation error is visible.
}
};
const handleDeleteOverride = async (userID: string) => {
try {
await onDeleteOverride(userID);
} catch {
// Keep the current UI state so the inline mutation error is visible.
}
};
if (isLoadingConfig) {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-1 items-center justify-center px-6 py-5">
<Spinner loading className="h-6 w-6" />
</div>
</div>
);
}
if (configError) {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-1 items-center justify-center px-6 py-5">
<div className="space-y-4 py-4 text-center">
<p className="text-sm text-content-secondary">
{getErrorMessage(
configError,
"Failed to load spend limit settings.",
)}
</p>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void refetchConfig()}
>
Retry
</Button>
</div>
</div>
</div>
);
}
const groupOverrides = configData?.group_overrides ?? [];
const overrides = configData?.overrides ?? [];
const unpricedModelCount = configData?.unpriced_model_count ?? 0;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<DefaultLimitController
key={defaultLimitKey}
initialValues={defaultLimitValues}
onSave={handleSaveDefault}
>
{({
enabled,
onEnabledChange,
period,
onPeriodChange,
amountDollars,
onAmountDollarsChange,
isAmountValid,
saveDefault,
}) => (
<>
<div className="flex-1 overflow-y-auto pb-24 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="space-y-10">
<DefaultLimitSection
adminBadge={<AdminBadge />}
enabled={enabled}
onEnabledChange={(nextEnabled) => {
handleResetUpdateConfig();
onEnabledChange(nextEnabled);
}}
period={period}
onPeriodChange={(nextPeriod) => {
handleResetUpdateConfig();
onPeriodChange(nextPeriod);
}}
amountDollars={amountDollars}
onAmountDollarsChange={(nextAmountDollars) => {
handleResetUpdateConfig();
onAmountDollarsChange(nextAmountDollars);
}}
unpricedModelCount={unpricedModelCount}
/>
<GroupLimitsSection
groupOverrides={groupOverrides}
showGroupForm={showGroupForm}
onShowGroupFormChange={handleShowGroupFormChange}
selectedGroup={selectedGroup}
onSelectedGroupChange={setSelectedGroup}
groupAmount={groupAmount}
onGroupAmountChange={setGroupAmount}
availableGroups={availableGroups}
groupAutocompleteNoOptionsText={
groupAutocompleteNoOptionsText
}
groupsLoading={isLoadingGroups}
editingGroupOverride={editingGroupOverride}
onEditGroupOverride={handleEditGroupOverride}
onAddGroupOverride={handleAddGroupOverride}
onDeleteGroupOverride={handleDeleteGroupOverride}
upsertPending={isUpsertingGroupOverride}
upsertError={upsertGroupOverrideError}
deletePending={isDeletingGroupOverride}
deleteError={deleteGroupOverrideError}
groupsError={groupsError}
/>
<UserOverridesSection
overrides={overrides}
showUserForm={showUserForm}
onShowUserFormChange={handleShowUserFormChange}
selectedUser={selectedUser}
onSelectedUserChange={setSelectedUser}
userOverrideAmount={userOverrideAmount}
onUserOverrideAmountChange={setUserOverrideAmount}
selectedUserAlreadyOverridden={
editingUserOverride ? false : selectedUserAlreadyOverridden
}
editingUserOverride={editingUserOverride}
onEditUserOverride={handleEditUserOverride}
onAddOverride={handleAddOverride}
onDeleteOverride={handleDeleteOverride}
upsertPending={isUpsertingOverride}
upsertError={upsertOverrideError}
deletePending={isDeletingOverride}
deleteError={deleteOverrideError}
/>
</div>
</div>
<div className="sticky bottom-0 flex shrink-0 flex-col gap-2 border-t border-border bg-surface-primary py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-h-4 text-xs">
{updateConfigError && (
<p className="m-0 text-content-destructive">
{getErrorMessage(
updateConfigError,
"Failed to save the default spend limit.",
)}
</p>
)}
{isUpdateConfigSuccess && (
<p className="m-0 text-content-success">Saved!</p>
)}
</div>
<Button
size="sm"
type="button"
onClick={() => void saveDefault()}
disabled={isUpdatingConfig || !isAmountValid}
>
{isUpdatingConfig ? (
<Spinner loading className="h-4 w-4" />
) : null}
Save default limit
</Button>
</div>{" "}
</>
)}
</DefaultLimitController>
</div>
);
};
@@ -0,0 +1,131 @@
import { type FC, type ReactNode, useState } from "react";
import type {
UpsertChatUsageLimitOverrideRequest,
User,
} from "#/api/typesGenerated";
import {
dollarsToMicros,
isPositiveFiniteDollarAmount,
microsToDollars,
} from "#/utils/currency";
interface EditingUserOverride {
user_id: string;
name: string;
username: string;
avatar_url: string;
}
type UserOverrideChildProps = {
showUserForm: boolean;
setShowUserForm: (show: boolean) => void;
selectedUserOverride: User | null;
setSelectedUserOverride: (user: User | null) => void;
userOverrideAmount: string;
setUserOverrideAmount: (amount: string) => void;
editingUserOverride: EditingUserOverride | null;
setEditingUserOverride: (override: EditingUserOverride | null) => void;
handleShowUserFormChange: (show: boolean) => void;
handleEditUserOverride: (
override: EditingUserOverride & {
spend_limit_micros: number | null;
},
) => void;
handleAddOverride: () => void;
existingUserIds: Set<string>;
selectedUserAlreadyOverridden: boolean;
};
interface UserOverrideControllerProps {
overrides: ReadonlyArray<{ user_id: string }>;
onUpsertOverride: (args: {
userID: string;
req: UpsertChatUsageLimitOverrideRequest;
onSuccess: () => void;
}) => void;
children: (props: UserOverrideChildProps) => ReactNode;
}
export const UserOverrideController: FC<UserOverrideControllerProps> = ({
overrides,
onUpsertOverride,
children,
}) => {
const [showUserForm, setShowUserForm] = useState(false);
const [selectedUserOverride, setSelectedUserOverride] = useState<User | null>(
null,
);
const [userOverrideAmount, setUserOverrideAmount] = useState("");
const [editingUserOverride, setEditingUserOverride] =
useState<EditingUserOverride | null>(null);
// Derived values.
const existingUserIds = new Set(overrides.map((o) => o.user_id));
const selectedUserAlreadyOverridden = selectedUserOverride
? existingUserIds.has(selectedUserOverride.id)
: false;
// Handlers.
const handleShowUserFormChange = (show: boolean) => {
setShowUserForm(show);
if (!show) {
setEditingUserOverride(null);
}
};
const handleEditUserOverride = (
override: EditingUserOverride & {
spend_limit_micros: number | null;
},
) => {
setEditingUserOverride({
user_id: override.user_id,
name: override.name,
username: override.username,
avatar_url: override.avatar_url,
});
setSelectedUserOverride(null);
setUserOverrideAmount(
override.spend_limit_micros !== null
? microsToDollars(override.spend_limit_micros).toString()
: "",
);
setShowUserForm(true);
};
const handleAddOverride = () => {
const targetUserID =
editingUserOverride?.user_id ?? selectedUserOverride?.id;
if (!targetUserID || !isPositiveFiniteDollarAmount(userOverrideAmount)) {
return;
}
onUpsertOverride({
userID: targetUserID,
req: { spend_limit_micros: dollarsToMicros(userOverrideAmount) },
onSuccess: () => {
setEditingUserOverride(null);
setSelectedUserOverride(null);
setUserOverrideAmount("");
setShowUserForm(false);
},
});
};
return children({
showUserForm,
setShowUserForm,
selectedUserOverride,
setSelectedUserOverride,
userOverrideAmount,
setUserOverrideAmount,
editingUserOverride,
setEditingUserOverride,
handleShowUserFormChange,
handleEditUserOverride,
handleAddOverride,
existingUserIds,
selectedUserAlreadyOverridden,
});
};
@@ -23,6 +23,7 @@ import { ConfirmDeleteDialog } from "../ConfirmDeleteDialog";
import { SectionHeader } from "../SectionHeader";
interface UserOverridesSectionProps {
hideHeader?: boolean;
overrides: ReadonlyArray<{
user_id: string;
name: string;
@@ -55,6 +56,7 @@ interface UserOverridesSectionProps {
}
export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
hideHeader,
overrides,
showUserForm,
onShowUserFormChange,
@@ -80,10 +82,12 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
return (
<section className="space-y-4">
<SectionHeader
label="Per-User Overrides"
description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default."
/>
{!hideHeader && (
<SectionHeader
label="Per-User Overrides"
description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default."
/>
)}
<div className="space-y-4">
{overrides.length > 0 ? (
<Table>
@@ -1 +0,0 @@
export { LimitsTab } from "./LimitsTab";
@@ -5,6 +5,9 @@ interface SectionHeaderProps {
description?: string;
badge?: ReactNode;
action?: ReactNode;
/** Controls heading size. "page" (default) renders a larger h2,
* "section" renders a smaller h3 for sub-sections. */
level?: "page" | "section";
}
export const SectionHeader: FC<SectionHeaderProps> = ({
@@ -12,24 +15,31 @@ export const SectionHeader: FC<SectionHeaderProps> = ({
description,
badge,
action,
}) => (
<>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex w-full items-center gap-2">
<h2 className="m-0 text-lg font-medium text-content-primary">
{label}
</h2>
{badge}
level = "page",
}) => {
const Heading = level === "section" ? "h3" : "h2";
const headingClass =
level === "section"
? "m-0 text-sm font-medium text-content-primary"
: "m-0 text-lg font-medium text-content-primary";
const descriptionClass =
level === "section"
? "m-0 mt-0.5 text-xs text-content-secondary"
: "m-0 mt-0.5 text-sm text-content-secondary";
return (
<>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex w-full items-center gap-2">
<Heading className={headingClass}>{label}</Heading>
{badge}
</div>
{description && <p className={descriptionClass}>{description}</p>}
</div>
{description && (
<p className="m-0 mt-0.5 text-sm text-content-secondary">
{description}
</p>
)}
{action}
</div>
{action}
</div>
<hr className="my-4 border-0 border-t border-solid border-border" />
</>
);
<hr className="my-4 border-0 border-t border-solid border-border" />
</>
);
};
@@ -20,7 +20,6 @@ import {
AlertTriangleIcon,
ArchiveIcon,
ArchiveRestoreIcon,
BarChart3Icon,
BoxesIcon,
CheckIcon,
ChevronDownIcon,
@@ -40,11 +39,11 @@ import {
PinIcon,
PinOffIcon,
SettingsIcon,
ShieldAlertIcon,
ShieldIcon,
SquarePenIcon,
Trash2Icon,
UserIcon,
WalletIcon,
WandSparklesIcon,
} from "lucide-react";
import {
@@ -1303,24 +1302,16 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
adminOnly
/>
<SettingsNavItem
icon={ShieldAlertIcon}
label="Limits"
active={sidebarView.section === "limits"}
to="/agents/settings/limits"
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={BarChart3Icon}
label="Usage"
active={sidebarView.section === "usage"}
to="/agents/settings/usage"
icon={WalletIcon}
label="Spend"
active={sidebarView.section === "spend"}
to="/agents/settings/spend"
state={location.state}
adminOnly
/>
<SettingsNavItem
icon={WandSparklesIcon}
label="Analytics"
label="Insights"
active={sidebarView.section === "insights"}
to="/agents/settings/insights"
state={location.state}
@@ -0,0 +1,130 @@
import type { FC } from "react";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { Button } from "#/components/Button/Button";
import {
DateRangePicker,
type DateRangeValue,
} from "#/components/DateRangePicker/DateRangePicker";
import { Spinner } from "#/components/Spinner/Spinner";
import { AdminBadge } from "./AdminBadge";
import { BackButton } from "./BackButton";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { SectionHeader } from "./SectionHeader";
interface SpendDrillInViewProps {
selectedUser: TypesGen.User | null;
isLoading: boolean;
isError: boolean;
error: unknown;
onRetry: () => void;
onBack: () => void;
displayDateRange: DateRangeValue;
onDateRangeChange: (value: DateRangeValue) => void;
dateRangeLabel: string;
summaryData: TypesGen.ChatCostSummary | undefined;
isSummaryLoading: boolean;
summaryError: unknown;
onSummaryRetry: () => void;
}
export const SpendDrillInView: FC<SpendDrillInViewProps> = ({
selectedUser,
isLoading,
isError,
error,
onRetry,
onBack,
displayDateRange,
onDateRangeChange,
dateRangeLabel,
summaryData,
isSummaryLoading,
summaryError,
onSummaryRetry,
}) => {
const backButton = <BackButton onClick={onBack} />;
const header = (
<SectionHeader
label="Spend management"
description="Review spend details for a specific user."
badge={<AdminBadge />}
action={
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
}
/>
);
if (isLoading) {
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div
role="status"
aria-label="Loading user details"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
</div>
);
}
if (isError || !selectedUser) {
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(error, "Failed to load user profile.")}
</p>
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
Retry
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border-default bg-surface-secondary px-4 py-3">
<AvatarData
title={selectedUser.name || selectedUser.username}
subtitle={`@${selectedUser.username}`}
src={selectedUser.avatar_url}
imgFallbackText={selectedUser.username}
/>
<div className="min-w-0 text-xs text-content-secondary">
<div>User ID: {selectedUser.id}</div>
<div>{dateRangeLabel}</div>
</div>
</div>
<ChatCostSummaryView
key={selectedUser.id}
summary={summaryData}
isLoading={isSummaryLoading}
error={summaryError}
onRetry={onSummaryRetry}
loadingLabel="Loading usage details"
emptyMessage="No usage data for this user in the selected period."
/>
</div>
);
};
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { formatUsageDateRange, toInclusiveDateRange } from "./dateRange";
describe("toInclusiveDateRange", () => {
it("subtracts 1ms when endDateIsExclusive is true and end date is midnight", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const endDate = new Date("2025-06-08T00:00:00.000");
const result = toInclusiveDateRange({ startDate, endDate }, true);
expect(result.endDate.getTime()).toBe(endDate.getTime() - 1);
});
it("returns unchanged when endDateIsExclusive is true and end date is not midnight", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const endDate = new Date("2025-06-08T14:30:00.000");
const result = toInclusiveDateRange({ startDate, endDate }, true);
expect(result.endDate).toBe(endDate);
});
it("returns unchanged when endDateIsExclusive is false and end date is midnight", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const endDate = new Date("2025-06-08T00:00:00.000");
const result = toInclusiveDateRange({ startDate, endDate }, false);
expect(result.endDate).toBe(endDate);
});
it("returns unchanged when endDateIsExclusive is false and end date is not midnight", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const endDate = new Date("2025-06-08T14:30:00.000");
const result = toInclusiveDateRange({ startDate, endDate }, false);
expect(result.endDate).toBe(endDate);
});
it("preserves startDate in all cases", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const midnightEnd = new Date("2025-06-08T00:00:00.000");
const nonMidnightEnd = new Date("2025-06-08T14:30:00.000");
const explicitMidnight = toInclusiveDateRange(
{ startDate, endDate: midnightEnd },
true,
);
expect(explicitMidnight.startDate).toBe(startDate);
const explicitNonMidnight = toInclusiveDateRange(
{ startDate, endDate: nonMidnightEnd },
true,
);
expect(explicitNonMidnight.startDate).toBe(startDate);
const implicitMidnight = toInclusiveDateRange(
{ startDate, endDate: midnightEnd },
false,
);
expect(implicitMidnight.startDate).toBe(startDate);
const implicitNonMidnight = toInclusiveDateRange(
{ startDate, endDate: nonMidnightEnd },
false,
);
expect(implicitNonMidnight.startDate).toBe(startDate);
});
});
describe("formatUsageDateRange", () => {
it("formats a basic date range without options", () => {
const result = formatUsageDateRange({
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-08T00:00:00.000"),
});
expect(result).toBe("Jun 1 – Jun 8, 2025");
});
it("shows previous day when endDateIsExclusive is true and end date is midnight", () => {
const result = formatUsageDateRange(
{
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-08T00:00:00.000"),
},
{ endDateIsExclusive: true },
);
expect(result).toBe("Jun 1 – Jun 7, 2025");
});
it("shows same day when endDateIsExclusive is true and end date is not midnight", () => {
const result = formatUsageDateRange(
{
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-08T14:30:00.000"),
},
{ endDateIsExclusive: true },
);
expect(result).toBe("Jun 1 – Jun 8, 2025");
});
it("shows same day when endDateIsExclusive is false and end date is midnight", () => {
const result = formatUsageDateRange(
{
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-08T00:00:00.000"),
},
{ endDateIsExclusive: false },
);
expect(result).toBe("Jun 1 – Jun 8, 2025");
});
it("formats a cross-month range", () => {
const result = formatUsageDateRange({
startDate: new Date("2025-05-28T00:00:00.000"),
endDate: new Date("2025-06-04T00:00:00.000"),
});
expect(result).toBe("May 28 – Jun 4, 2025");
});
it("formats a same-month range", () => {
const result = formatUsageDateRange({
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-15T00:00:00.000"),
});
expect(result).toBe("Jun 1 – Jun 15, 2025");
});
it("formats a cross-year range without ambiguity", () => {
const result = formatUsageDateRange({
startDate: new Date("2025-12-28T00:00:00.000"),
endDate: new Date("2026-01-04T00:00:00.000"),
});
// Start date omits year, end date includes it. The label reads
// as "Dec 28 – Jan 4, 2026" which is unambiguous enough for a
// 30-day range label (the start year is implied).
expect(result).toBe("Dec 28 – Jan 4, 2026");
});
});
@@ -0,0 +1,54 @@
import dayjs from "dayjs";
import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker";
/**
* Returns true when the given date falls exactly on local midnight
* (00:00:00.000). DateRangePicker's `toBoundary` produces local
* midnight via `dayjs(to).startOf("day").add(1, "day").toDate()`,
* so we use local-time methods to match that convention.
*/
function isMidnight(date: Date): boolean {
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
);
}
/**
* When the user picks an explicit date range whose end boundary is
* midnight of the following day, adjust it by −1 ms so the
* DateRangePicker highlights the inclusive end date.
*/
export function toInclusiveDateRange(
dateRange: DateRangeValue,
endDateIsExclusive: boolean,
): DateRangeValue {
if (endDateIsExclusive && isMidnight(dateRange.endDate)) {
return {
startDate: dateRange.startDate,
endDate: new Date(dateRange.endDate.getTime() - 1),
};
}
return dateRange;
}
/**
* Format a date range for display. When `endDateIsExclusive` is true
* and the end date is midnight, the formatted label shows the
* preceding day.
*/
export function formatUsageDateRange(
value: DateRangeValue,
options?: { endDateIsExclusive?: boolean },
): string {
const adjusted = toInclusiveDateRange(
value,
options?.endDateIsExclusive ?? false,
);
return `${dayjs(adjusted.startDate).format("MMM D")} – ${dayjs(
adjusted.endDate,
).format("MMM D, YYYY")}`;
}
+13 -8
View File
@@ -6,6 +6,7 @@ import {
Outlet,
Route,
ScrollRestoration,
useLocation,
} from "react-router";
import { GlobalErrorBoundary } from "./components/ErrorBoundary/GlobalErrorBoundary";
import { Loader } from "./components/Loader/Loader";
@@ -371,11 +372,8 @@ const AgentSettingsModelsPage = lazy(
const AgentSettingsMCPServersPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsMCPServersPage"),
);
const AgentSettingsLimitsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsLimitsPage"),
);
const AgentSettingsUsagePage = lazy(
() => import("./pages/AgentsPage/AgentSettingsUsagePage"),
const AgentSettingsSpendPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsSpendPage"),
);
const AgentSettingsInsightsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsInsightsPage"),
@@ -471,6 +469,12 @@ const groupsRouter = () => {
);
};
/** Redirect that preserves the current query string. */
const NavigateWithSearch = ({ to }: { to: string }) => {
const location = useLocation();
return <Navigate to={{ pathname: to, search: location.search }} replace />;
};
export const router = createBrowserRouter(
createRoutesFromChildren(
<Route element={<GlobalLayout />} errorElement={<GlobalErrorBoundary />}>
@@ -712,12 +716,13 @@ export const router = createBrowserRouter(
path="mcp-servers"
element={<AgentSettingsMCPServersPage />}
/>
<Route path="limits" element={<AgentSettingsLimitsPage />} />
<Route path="usage" element={<AgentSettingsUsagePage />} />
<Route path="spend" element={<AgentSettingsSpendPage />} />
<Route path="limits" element={<Navigate to="spend" replace />} />
<Route path="usage" element={<NavigateWithSearch to="spend" />} />
<Route path="insights" element={<AgentSettingsInsightsPage />} />
<Route path="templates" element={<AgentSettingsTemplatesPage />} />
</Route>
<Route path="analytics" element={<AgentAnalyticsPage />} />{" "}
<Route path="analytics" element={<AgentAnalyticsPage />} />
<Route
path=":agentId"
element={