diff --git a/docs/ai-coder/agents/platform-controls/index.md b/docs/ai-coder/agents/platform-controls/index.md index fba1c8dc66..a4685878a7 100644 --- a/docs/ai-coder/agents/platform-controls/index.md +++ b/docs/ai-coder/agents/platform-controls/index.md @@ -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 diff --git a/docs/ai-coder/agents/platform-controls/usage-insights.md b/docs/ai-coder/agents/platform-controls/usage-insights.md index 7368961e70..7d56800e01 100644 --- a/docs/ai-coder/agents/platform-controls/usage-insights.md +++ b/docs/ai-coder/agents/platform-controls/usage-insights.md @@ -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. diff --git a/docs/manifest.json b/docs/manifest.json index 3bf7b823cf..e70886cacb 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -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"] diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 0620b176a9..1201fe9cea 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -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) => Promise + )({ + ...pageParams, + payload, + }); + expect(API.experimental.getChatCostUsers).toHaveBeenCalledWith( + expect.objectContaining({ username: undefined, limit: 25, offset: 25 }), + ); }); }); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 4ad2b03b11..7b4076a0e3 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -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; diff --git a/site/src/pages/AgentsPage/AgentSettingsLimitsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsLimitsPage.tsx deleted file mode 100644 index 078f9e8cb9..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsLimitsPage.tsx +++ /dev/null @@ -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 ( - - 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 - } - /> - - ); -}; - -export default AgentSettingsLimitsPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsSpendPage.tsx b/site/src/pages/AgentsPage/AgentSettingsSpendPage.tsx new file mode 100644 index 0000000000..510d468369 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsSpendPage.tsx @@ -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 = ({ 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 ( + + 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()} + /> + + ); +}; + +export default AgentSettingsSpendPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsSpendPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsSpendPageView.stories.tsx new file mode 100644 index 0000000000..c22529f5bd --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsSpendPageView.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +// ── 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" }), + ); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx new file mode 100644 index 0000000000..2156a64bf7 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx @@ -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 ( + + + + +
+ + {user.name || user.username} + + } + subtitle={ + @{user.username} + } + src={user.avatar_url} + imgFallbackText={user.username} + /> +
+
+ {user.name || user.username} +
+
+ + {formatCostMicros(user.total_cost_micros)} + + + {user.message_count.toLocaleString()} + + + {user.chat_count.toLocaleString()} + + + {formatTokenCount(user.total_input_tokens)} + + + {formatTokenCount(user.total_output_tokens)} + + + {formatTokenCount(user.total_cache_read_tokens)} + + + {formatTokenCount(user.total_cache_creation_tokens)} + +
+ ); +}; + +// ── 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 ( + + ); + } + // ── List mode ── + return ( + + {(groupCtrl) => ( + + {(userCtrl) => ( +
+ } + /> + + {isLoadingConfig ? ( +
+ +
+ ) : configError ? ( +
+

+ {getErrorMessage( + configError, + "Failed to load spend limit settings.", + )} +

+ +
+ ) : ( + <> + {/* Section 1: Default spend limit */} + + {({ + enabled, + onEnabledChange, + period, + onPeriodChange, + amountDollars, + onAmountDollarsChange, + isAmountValid, + saveDefault, + }) => ( +
+ + { + handleResetUpdateConfig(); + onEnabledChange(v); + }} + period={period} + onPeriodChange={(v) => { + handleResetUpdateConfig(); + onPeriodChange(v); + }} + amountDollars={amountDollars} + onAmountDollarsChange={(v) => { + handleResetUpdateConfig(); + onAmountDollarsChange(v); + }} + unpricedModelCount={unpricedModelCount} + /> +
+
+ {updateConfigError && ( +

+ {getErrorMessage( + updateConfigError, + "Failed to save the default spend limit.", + )} +

+ )} + {isUpdateConfigSuccess && ( +

Saved!

+ )} +
+ +
+
+ )} +
+ + {/* Section 2: Group limits */} +
+ + { + userCtrl.handleShowUserFormChange(false); + groupCtrl.handleEditGroupOverride(override); + }} + onAddGroupOverride={groupCtrl.handleAddGroupOverride} + onDeleteGroupOverride={onDeleteGroupOverride} + upsertPending={isUpsertingGroupOverride} + upsertError={upsertGroupOverrideError} + deletePending={isDeletingGroupOverride} + deleteError={deleteGroupOverrideError} + groupsError={groupsError} + /> +
+ + )} + + {/* Section 3: Per-user spend */} +
+ +
+ + Date range + + +
+ {!configError && !isLoadingConfig && ( + { + groupCtrl.handleShowGroupFormChange(false); + userCtrl.handleEditUserOverride(override); + }} + onAddOverride={userCtrl.handleAddOverride} + onDeleteOverride={onDeleteOverride} + upsertPending={isUpsertingOverride} + upsertError={upsertOverrideError} + deletePending={isDeletingOverride} + deleteError={deleteOverrideError} + /> + )} + {/* Search */} +
+
+ +
+
+ {/* Loading state */} + {usersQuery.isLoading && ( +
+ +
+ )} + {/* Error state */} + {usersQuery.error != null && ( +
+

+ {getErrorMessage( + usersQuery.error, + "Failed to load usage data.", + )} +

+ +
+ )} + {/* User table + pagination */} + {usersQuery.data && ( +
+ {usersQuery.isFetching && !usersQuery.isLoading && ( +
+ +
+ )} + {usersQuery.data.users.length === 0 ? ( +

+ No usage data for this period. +

+ ) : ( + +
+ + + + User + + Cost + + + Messages + + + Chats + + + Input + + + Output + + + Cache Read + + + Cache Write + + + + + {usersQuery.data.users.map((user) => ( + + ))} + +
+
+
+ )} +
+ )} +
+
+ )} +
+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsUsagePage.tsx b/site/src/pages/AgentsPage/AgentSettingsUsagePage.tsx deleted file mode 100644 index 8158601639..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsUsagePage.tsx +++ /dev/null @@ -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 = ({ 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 ( - - 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()} - /> - - ); -}; - -export default AgentSettingsUsagePage; diff --git a/site/src/pages/AgentsPage/AgentSettingsUsagePageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsUsagePageView.stories.tsx deleted file mode 100644 index 67303f8b9e..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsUsagePageView.stories.tsx +++ /dev/null @@ -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; - -export default meta; -type Story = StoryObj; - -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(); - }, -}; diff --git a/site/src/pages/AgentsPage/AgentSettingsUsagePageView.tsx b/site/src/pages/AgentsPage/AgentSettingsUsagePageView.tsx deleted file mode 100644 index 78bab31c68..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsUsagePageView.tsx +++ /dev/null @@ -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 ( - - - - - - {formatCostMicros(user.total_cost_micros)} - - - {user.message_count.toLocaleString()} - - - {user.chat_count.toLocaleString()} - - - {formatTokenCount(user.total_input_tokens)} - - - {formatTokenCount(user.total_output_tokens)} - - - {formatTokenCount(user.total_cache_read_tokens)} - - - {formatTokenCount(user.total_cache_creation_tokens)} - - - ); -}; - -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 = ( - } - action={ - - } - /> - ); - - if (selectedUserId) { - const backButton = ; - - if (isSelectedUserLoading) { - return ( -
-
- {backButton} - {header} -
-
- -
-
- ); - } - - if (isSelectedUserError || !selectedUser) { - return ( -
-
- {backButton} - {header} -
-
-

- {getErrorMessage( - selectedUserError, - "Failed to load user profile.", - )} -

{" "} - -
-
- ); - } - - return ( -
-
- {backButton} - {header} -
-
- -
-
User ID: {selectedUser.id}
-
{dateRangeLabel}
-
-
- -
- ); - } - - return ( -
- {header} -
-
- { - onSearchFilterChange(value); - onPageChange(1); - }} - placeholder="Search by name or username" - aria-label="Search usage by name or username" - /> -
- {usersData && ( - - )} -
- {isUsersLoading && ( -
- -
- )} - - {usersError != null && ( -
-

- {getErrorMessage(usersError, "Failed to load usage data.")} -

{" "} - -
- )} - - {usersData && ( -
- {isUsersFetching && !isUsersLoading && ( -
- -
- )} - {usersData.users.length === 0 ? ( -

- No usage data for this period. -

- ) : ( - <> -
- - - - User - - Total Cost - - - Messages - - - Chats - - - Input Tokens - - - Output Tokens - - - Cache Read - - - Cache Write - - - - - {usersData.users.map((user) => ( - - ))} - -
-
- - - )} -
- )} -
- ); -}; diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index e4c9a8b485..043e04d8f6 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -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 = () => ( - -); - const agentsRouting = { path: "/agents", useStoryElement: true, @@ -238,7 +204,11 @@ const agentsRouting = { children: [ { index: true, element: }, { path: "behavior", element: }, - { path: "usage", element: }, + { path: "spend", element: }, + { + path: "usage", + element: , + }, ], }, { path: "analytics", element: }, @@ -358,6 +328,21 @@ const meta: Meta = { 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(); }); diff --git a/site/src/pages/AgentsPage/components/ChatCostSummaryView.stories.tsx b/site/src/pages/AgentsPage/components/ChatCostSummaryView.stories.tsx index e37eeff262..cda89483e6 100644 --- a/site/src/pages/AgentsPage/components/ChatCostSummaryView.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatCostSummaryView.stories.tsx @@ -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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatCostSummaryView.tsx b/site/src/pages/AgentsPage/components/ChatCostSummaryView.tsx index f373bc193f..0107f3d821 100644 --- a/site/src/pages/AgentsPage/components/ChatCostSummaryView.tsx +++ b/site/src/pages/AgentsPage/components/ChatCostSummaryView.tsx @@ -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 = ({ 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 (
= ({ 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 = ({

) : ( <> -
- +
+
- - Model - Provider - Cost - - Messages - - Input - Output - - Cache Read - - - Cache Write - + + Model + Provider + Cost + Messages + Input + Output + Cache Read + Cache Write - {summary.by_model.map((model) => ( - - - {model.display_name || model.model} - - + {pagedModels.map((model) => ( + + {model.display_name || model.model} + {model.provider} - + {formatCostMicros(model.total_cost_micros)} - + {model.message_count.toLocaleString()} - + {formatTokenCount(model.total_input_tokens)} - + {formatTokenCount(model.total_output_tokens)} - + {formatTokenCount(model.total_cache_read_tokens)} - + {formatTokenCount(model.total_cache_creation_tokens)} ))}
+ {summary.by_model.length > modelPageSize && ( +
+ 1} + hasNextPage={ + clampedModelPage * modelPageSize < summary.by_model.length + } + /> +
+ )}
-
- +
+
- - Conversation - Cost - - Messages - - Input - Output - - Cache Read - - - Cache Write - + + Agent + Cost + Messages + Input + Output + Cache Read + Cache Write - {summary.by_chat.map((chat) => ( - - - {chat.chat_title || ( - - Untitled conversation + {pagedChats.map((chat) => ( + + + {chat.chat_title ? ( + + + + {chat.chat_title} + + + {chat.chat_title} + + ) : ( + + Untitled agent )} - + {formatCostMicros(chat.total_cost_micros)} - + {chat.message_count.toLocaleString()} - + {formatTokenCount(chat.total_input_tokens)} - + {formatTokenCount(chat.total_output_tokens)} - + {formatTokenCount(chat.total_cache_read_tokens)} - + {formatTokenCount(chat.total_cache_creation_tokens)} ))}
+ {summary.by_chat.length > chatPageSize && ( +
+ 1} + hasNextPage={ + clampedChatPage * chatPageSize < summary.by_chat.length + } + /> +
+ )}
)} diff --git a/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitController.tsx b/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitController.tsx new file mode 100644 index 0000000000..073ab2044f --- /dev/null +++ b/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitController.tsx @@ -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 = ({ + initialValues, + onSave, + children, +}) => { + const [enabled, setEnabled] = useState(initialValues.enabled); + const [period, setPeriod] = useState( + 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, + }); +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitSection.tsx b/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitSection.tsx index ab2763695a..dc2d92aede 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitSection.tsx +++ b/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitSection.tsx @@ -28,6 +28,7 @@ interface DefaultLimitSectionProps { onAmountDollarsChange: (amount: string) => void; unpricedModelCount: number; adminBadge: ReactNode; + hideHeader?: boolean; } export const DefaultLimitSection: FC = ({ @@ -39,17 +40,20 @@ export const DefaultLimitSection: FC = ({ onAmountDollarsChange, unpricedModelCount, adminBadge, + hideHeader, }) => { const periodId = useId(); const amountId = useId(); return (
- + {!hideHeader && ( + + )}
diff --git a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx b/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx index ef351560ee..3c31a22d21 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx +++ b/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx @@ -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 = ({ + hideHeader, groupOverrides, showGroupForm, onShowGroupFormChange, @@ -91,10 +93,12 @@ export const GroupLimitsSection: FC = ({ return (
- + {!hideHeader && ( + + )}
{groupOverrides.length > 0 ? ( diff --git a/site/src/pages/AgentsPage/components/LimitsTab/GroupOverrideController.tsx b/site/src/pages/AgentsPage/components/LimitsTab/GroupOverrideController.tsx new file mode 100644 index 0000000000..2ccd1ae44f --- /dev/null +++ b/site/src/pages/AgentsPage/components/LimitsTab/GroupOverrideController.tsx @@ -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; + availableGroups: Group[]; + groupAutocompleteNoOptionsText: string; +}; + +interface GroupOverrideControllerProps { + groupOverrides: ReadonlyArray<{ group_id: string }>; + groups: ReadonlyArray; + isLoadingGroups: boolean; + onUpsertGroupOverride: (args: { + groupID: string; + req: UpsertChatUsageLimitGroupOverrideRequest; + onSuccess: () => void; + }) => void; + children: (props: GroupOverrideChildProps) => ReactNode; +} + +export const GroupOverrideController: FC = ({ + groupOverrides, + groups, + isLoadingGroups, + onUpsertGroupOverride, + children, +}) => { + const [showGroupForm, setShowGroupForm] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + const [groupAmount, setGroupAmount] = useState(""); + const [editingGroupOverride, setEditingGroupOverride] = + useState(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, + }); +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/LimitsTab.tsx b/site/src/pages/AgentsPage/components/LimitsTab/LimitsTab.tsx deleted file mode 100644 index 66a2e7ac3d..0000000000 --- a/site/src/pages/AgentsPage/components/LimitsTab/LimitsTab.tsx +++ /dev/null @@ -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; - children: (props: { - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - period: ChatUsageLimitPeriod; - onPeriodChange: (period: ChatUsageLimitPeriod) => void; - amountDollars: string; - onAmountDollarsChange: (amount: string) => void; - isAmountValid: boolean; - saveDefault: () => Promise; - }) => ReactNode; -} - -const AdminBadge: FC = () => ( - - - - - - Admin - - - - Only visible to deployment administrators. - - - -); - -const DefaultLimitController: FC = ({ - initialValues, - onSave, - children, -}) => { - const [enabled, setEnabled] = useState(initialValues.enabled); - const [period, setPeriod] = useState( - 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; - isUpdatingConfig: boolean; - updateConfigError: Error | null; - isUpdateConfigSuccess: boolean; - resetUpdateConfig: () => void; - // Upsert user override mutation. - onUpsertOverride: (args: { - userID: string; - req: UpsertChatUsageLimitOverrideRequest; - }) => Promise; - isUpsertingOverride: boolean; - upsertOverrideError: Error | null; - // Delete user override mutation. - onDeleteOverride: (userID: string) => Promise; - isDeletingOverride: boolean; - deleteOverrideError: Error | null; - // Upsert group override mutation. - onUpsertGroupOverride: (args: { - groupID: string; - req: UpsertChatUsageLimitGroupOverrideRequest; - }) => Promise; - isUpsertingGroupOverride: boolean; - upsertGroupOverrideError: Error | null; - // Delete group override mutation. - onDeleteGroupOverride: (groupID: string) => Promise; - isDeletingGroupOverride: boolean; - deleteGroupOverrideError: Error | null; -} - -export const LimitsTab: FC = ({ - 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(null); - const [groupAmount, setGroupAmount] = useState(""); - const [showUserForm, setShowUserForm] = useState(false); - const [selectedUser, setSelectedUser] = useState(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 ( -
-
- -
-
- ); - } - - if (configError) { - return ( -
-
-
-

- {getErrorMessage( - configError, - "Failed to load spend limit settings.", - )} -

- -
-
-
- ); - } - - const groupOverrides = configData?.group_overrides ?? []; - const overrides = configData?.overrides ?? []; - const unpricedModelCount = configData?.unpriced_model_count ?? 0; - - return ( -
- - {({ - enabled, - onEnabledChange, - period, - onPeriodChange, - amountDollars, - onAmountDollarsChange, - isAmountValid, - saveDefault, - }) => ( - <> -
-
- } - enabled={enabled} - onEnabledChange={(nextEnabled) => { - handleResetUpdateConfig(); - onEnabledChange(nextEnabled); - }} - period={period} - onPeriodChange={(nextPeriod) => { - handleResetUpdateConfig(); - onPeriodChange(nextPeriod); - }} - amountDollars={amountDollars} - onAmountDollarsChange={(nextAmountDollars) => { - handleResetUpdateConfig(); - onAmountDollarsChange(nextAmountDollars); - }} - unpricedModelCount={unpricedModelCount} - /> - - -
-
-
-
- {updateConfigError && ( -

- {getErrorMessage( - updateConfigError, - "Failed to save the default spend limit.", - )} -

- )} - {isUpdateConfigSuccess && ( -

Saved!

- )} -
- -
{" "} - - )} -
-
- ); -}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/UserOverrideController.tsx b/site/src/pages/AgentsPage/components/LimitsTab/UserOverrideController.tsx new file mode 100644 index 0000000000..99f406592a --- /dev/null +++ b/site/src/pages/AgentsPage/components/LimitsTab/UserOverrideController.tsx @@ -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; + 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 = ({ + overrides, + onUpsertOverride, + children, +}) => { + const [showUserForm, setShowUserForm] = useState(false); + const [selectedUserOverride, setSelectedUserOverride] = useState( + null, + ); + const [userOverrideAmount, setUserOverrideAmount] = useState(""); + const [editingUserOverride, setEditingUserOverride] = + useState(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, + }); +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx b/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx index 0b58d64095..ad25f7ae8c 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx +++ b/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx @@ -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 = ({ + hideHeader, overrides, showUserForm, onShowUserFormChange, @@ -80,10 +82,12 @@ export const UserOverridesSection: FC = ({ return (
- + {!hideHeader && ( + + )}
{overrides.length > 0 ? (
diff --git a/site/src/pages/AgentsPage/components/LimitsTab/index.ts b/site/src/pages/AgentsPage/components/LimitsTab/index.ts deleted file mode 100644 index a4213acf8f..0000000000 --- a/site/src/pages/AgentsPage/components/LimitsTab/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LimitsTab } from "./LimitsTab"; diff --git a/site/src/pages/AgentsPage/components/SectionHeader.tsx b/site/src/pages/AgentsPage/components/SectionHeader.tsx index 9f462eddb7..1ab7793f31 100644 --- a/site/src/pages/AgentsPage/components/SectionHeader.tsx +++ b/site/src/pages/AgentsPage/components/SectionHeader.tsx @@ -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 = ({ @@ -12,24 +15,31 @@ export const SectionHeader: FC = ({ description, badge, action, -}) => ( - <> -
-
-
-

- {label} -

- {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 ( + <> +
+
+
+ {label} + {badge} +
+ {description &&

{description}

}
- {description && ( -

- {description} -

- )} + {action}
- {action} -
-
- -); +
+ + ); +}; diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index 125da20408..1a6c6f889d 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -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 = (props) => { adminOnly /> - 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 = ({ + selectedUser, + isLoading, + isError, + error, + onRetry, + onBack, + displayDateRange, + onDateRangeChange, + dateRangeLabel, + summaryData, + isSummaryLoading, + summaryError, + onSummaryRetry, +}) => { + const backButton = ; + + const header = ( + } + action={ + + } + /> + ); + + if (isLoading) { + return ( +
+
+ {backButton} + {header} +
+
+ +
+
+ ); + } + + if (isError || !selectedUser) { + return ( +
+
+ {backButton} + {header} +
+
+

+ {getErrorMessage(error, "Failed to load user profile.")} +

+ +
+
+ ); + } + + return ( +
+
+ {backButton} + {header} +
+
+ +
+
User ID: {selectedUser.id}
+
{dateRangeLabel}
+
+
+ +
+ ); +}; diff --git a/site/src/pages/AgentsPage/utils/dateRange.test.ts b/site/src/pages/AgentsPage/utils/dateRange.test.ts new file mode 100644 index 0000000000..63b5f4f50a --- /dev/null +++ b/site/src/pages/AgentsPage/utils/dateRange.test.ts @@ -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"); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/dateRange.ts b/site/src/pages/AgentsPage/utils/dateRange.ts new file mode 100644 index 0000000000..6406323748 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/dateRange.ts @@ -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")}`; +} diff --git a/site/src/router.tsx b/site/src/router.tsx index b5c4be7ad5..6af48eb88f 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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 ; +}; + export const router = createBrowserRouter( createRoutesFromChildren( } errorElement={}> @@ -712,12 +716,13 @@ export const router = createBrowserRouter( path="mcp-servers" element={} /> - } /> - } /> + } /> + } /> + } /> } /> } /> - } />{" "} + } />