diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index cc1887adf7..b05cb2350c 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -35,6 +35,9 @@ const AISettingsSidebarView: FC = ({ {permissions.editDeploymentConfig && ( Models )} + {permissions.editDeploymentConfig && ( + Spend + )} {permissions.editDeploymentConfig && ( Instructions diff --git a/site/src/pages/AgentsPage/AgentSettingsSpendPage.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx similarity index 83% rename from site/src/pages/AgentsPage/AgentSettingsSpendPage.tsx rename to site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx index 510d468369..e800775495 100644 --- a/site/src/pages/AgentsPage/AgentSettingsSpendPage.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx @@ -20,12 +20,14 @@ 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"; +import { SpendPageView } from "./SpendPageView"; const startDateSearchParam = "startDate"; const endDateSearchParam = "endDate"; +const tabSearchParam = "tab"; const DEFAULT_DATE_RANGE_DAYS = 30; const SEARCH_DEBOUNCE_MS = 300; +const USAGE_USERS_PAGE_SIZE = 10; const getDefaultDateRange = (now?: dayjs.Dayjs): DateRangeValue => { const end = now ?? dayjs(); @@ -35,18 +37,14 @@ const getDefaultDateRange = (now?: dayjs.Dayjs): DateRangeValue => { }; }; -interface AgentSettingsSpendPageProps { - /** Override the current time for date range calculation. Used for - * deterministic Storybook snapshots. */ +interface SpendPageProps { now?: dayjs.Dayjs; } -const AgentSettingsSpendPage: FC = ({ now }) => { +const SpendPage: FC = ({ now }) => { const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); - // --------------- Limits queries & mutations --------------- - const configQuery = useQuery(chatUsageLimitConfig()); const groupsQuery = useQuery(groups()); @@ -66,12 +64,12 @@ const AgentSettingsSpendPage: FC = ({ now }) => { deleteChatUsageLimitGroupOverride(queryClient), ); - // --------------- Usage state & queries --------------- - const [searchParams, setSearchParams] = useSearchParams(); const searchFilter = searchParams.get("search") ?? ""; const debouncedSearch = useDebouncedValue(searchFilter, SEARCH_DEBOUNCE_MS); + const tabParam = searchParams.get(tabSearchParam); + const activeTab = tabParam === "usage" ? "usage" : "limits"; const setSearchFilter = (value: string) => { setSearchParams( @@ -82,7 +80,6 @@ const AgentSettingsSpendPage: FC = ({ now }) => { } else { next.delete("search"); } - // Reset to page 1 when the search changes. next.delete("page"); return next; }, @@ -93,7 +90,6 @@ const AgentSettingsSpendPage: FC = ({ now }) => { 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; @@ -120,23 +116,42 @@ const AgentSettingsSpendPage: FC = ({ now }) => { 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 onActiveTabChange = (tab: "limits" | "usage") => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (tab === "usage") { + next.set(tabSearchParam, tab); + } else { + next.delete(tabSearchParam); + } + return next; + }, + { replace: true }, + ); }; - const usersQuery = usePaginatedQuery( - paginatedChatCostUsers({ + const onDateRangeChange = (value: DateRangeValue) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set(startDateSearchParam, value.startDate.toISOString()); + next.set(endDateSearchParam, value.endDate.toISOString()); + next.delete("page"); + return next; + }, + { replace: true }, + ); + }; + + const usersQuery = usePaginatedQuery({ + ...paginatedChatCostUsers({ ...dateRangeParams, username: debouncedSearch, }), - ); + recordsPerPage: USAGE_USERS_PAGE_SIZE, + preventScrollReset: true, + }); const selectedUserId = searchParams.get("user") || null; const selectedUserQuery = useQuery({ @@ -151,8 +166,7 @@ const AgentSettingsSpendPage: FC = ({ now }) => { return ( - = ({ now }) => { groupsData={groupsQuery.data} isLoadingGroups={groupsQuery.isLoading} groupsError={groupsQuery.isError ? groupsQuery.error : null} - onUpdateConfig={updateConfigMutation.mutate} + onUpdateConfig={(req, options) => { + updateConfigMutation.mutate(req, { + onSuccess: options?.onSuccess, + }); + }} isUpdatingConfig={updateConfigMutation.isPending} updateConfigError={ updateConfigMutation.isError ? updateConfigMutation.error : null } - isUpdateConfigSuccess={updateConfigMutation.isSuccess} resetUpdateConfig={updateConfigMutation.reset} onUpsertOverride={({ userID, req, onSuccess }) => upsertOverrideMutation.mutate({ userID, req }, { onSuccess }) @@ -195,7 +212,6 @@ const AgentSettingsSpendPage: FC = ({ now }) => { ? deleteGroupOverrideMutation.error : null } - // Usage data dateRange={dateRange} endDateIsExclusive={endDateIsExclusive} onDateRangeChange={onDateRangeChange} @@ -226,9 +242,11 @@ const AgentSettingsSpendPage: FC = ({ now }) => { isSummaryLoading={summaryQuery.isLoading} summaryError={summaryQuery.error} onSummaryRetry={() => void summaryQuery.refetch()} + activeTab={activeTab} + onActiveTabChange={onActiveTabChange} /> ); }; -export default AgentSettingsSpendPage; +export default SpendPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsSpendPageView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx similarity index 85% rename from site/src/pages/AgentsPage/AgentSettingsSpendPageView.stories.tsx rename to site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx index c22529f5bd..6b12705ad1 100644 --- a/site/src/pages/AgentsPage/AgentSettingsSpendPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx @@ -1,10 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; 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 ────────────────────────────────────────────────── +import { SpendPageView } from "./SpendPageView"; const mockUsers: TypesGen.ChatCostUserRollup[] = [ { @@ -168,8 +167,6 @@ const defaultDateRange = { 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; @@ -221,10 +218,7 @@ function mockUsersQuery( }; } -// 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, @@ -233,7 +227,6 @@ const baseProps = { groupsError: null as Error | null, isUpdatingConfig: false, updateConfigError: null as Error | null, - isUpdateConfigSuccess: false, isUpsertingOverride: false, upsertOverrideError: null as Error | null, isDeletingOverride: false, @@ -242,7 +235,6 @@ const baseProps = { upsertGroupOverrideError: null as Error | null, isDeletingGroupOverride: false, deleteGroupOverrideError: null as Error | null, - // Usage data. dateRange: defaultDateRange, endDateIsExclusive: false, searchFilter: "", @@ -258,8 +250,21 @@ const baseProps = { }; const meta = { - title: "pages/AgentsPage/AgentSettingsSpendPageView", - component: AgentSettingsSpendPageView, + title: "pages/AISettingsPage/SpendPage/SpendPageView", + component: SpendPageView, + render: (args) => { + const [activeTab, setActiveTab] = useState(args.activeTab); + return ( + { + setActiveTab(tab); + args.onActiveTabChange(tab); + }} + /> + ); + }, args: { ...baseProps, refetchConfig: fn(), @@ -275,13 +280,13 @@ const meta = { onClearSelectedUser: fn(), onSelectUser: fn(), onSummaryRetry: fn(), + activeTab: "limits", + onActiveTabChange: fn(), }, -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; - -// ── Stories ──────────────────────────────────────────────────── +type Story = StoryObj; export const SpendWithLimitsAndUsers: Story = { args: { @@ -292,17 +297,18 @@ export const SpendWithLimitsAndUsers: Story = { 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 canvas.findByText("Spend limits and usage"); + await expect( + canvas.getByRole("switch", { name: "Spend limit" }), + ).toBeInTheDocument(); await expect(canvas.getByText("Group limits")).toBeInTheDocument(); - await expect(canvas.getByText("Per-user spend")).toBeInTheDocument(); + await expect(canvas.getByText("Usage")).toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - // 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(); @@ -325,7 +331,8 @@ export const SpendUsersEmpty: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText("Spend management"); + await canvas.findByText("Spend limits and usage"); + await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); await expect( await canvas.findByText("No usage data for this period."), ).toBeInTheDocument(); @@ -343,12 +350,10 @@ export const SpendUserDrillIn: Story = { 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(); }, }; @@ -366,10 +371,8 @@ export const SpendUserDrillInAndBack: Story = { 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(); }, }; @@ -418,10 +421,10 @@ export const SpendRefetchOverlay: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Table data should be visible behind the overlay. + await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); + await canvas.findByText("Alice Liddell"); - // The refetch overlay spinner should be shown. await expect( await canvas.findByRole("status", { name: "Refreshing usage" }), ).toBeInTheDocument(); @@ -442,11 +445,11 @@ export const SpendConfigError: Story = { 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 userEvent.click(canvas.getByRole("tab", { name: "Usage" })); + await expect(canvas.getByText("Alice Liddell")).toBeInTheDocument(); await expect(canvas.getByText("Bob Builder")).toBeInTheDocument(); }, @@ -461,10 +464,10 @@ export const SpendUsersLoading: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Config sections should still render. - await canvas.findByText("Default spend limit"); + await canvas.findByRole("switch", { name: "Spend limit" }); + + await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - // The loading spinner for users should be visible. await expect( await canvas.findByRole("status", { name: "Loading usage" }), ).toBeInTheDocument(); @@ -482,10 +485,10 @@ export const SpendUsersError: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Config sections should still render. - await canvas.findByText("Default spend limit"); + await canvas.findByRole("switch", { name: "Spend limit" }); + + await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - // The usage error should be visible with retry. await expect( canvas.getByText("Failed to load usage data"), ).toBeInTheDocument(); @@ -502,9 +505,10 @@ export const SpendUserClickToDrillIn: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - // Click the first user row. + await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); + const row = await canvas.findByRole("button", { - name: /Alice Liddell/, + name: /^View details for Alice Liddell/, }); await userEvent.click(row); diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx new file mode 100644 index 0000000000..a8053f25c3 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx @@ -0,0 +1,441 @@ +import type { FC } from "react"; +import { getErrorMessage } from "#/api/errors"; +import type * as TypesGen from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker"; +import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "#/components/Tabs/Tabs"; +import { + TemporarySavedState, + useTemporarySavedState, +} from "#/components/TemporarySavedState/TemporarySavedState"; +import { dollarsToMicros, microsToDollars } from "#/utils/currency"; +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 { SpendDrillInView } from "./components/SpendDrillInView"; +import { SpendSectionHeader } from "./components/SpendSectionHeader"; +import { UsageTab } from "./components/UsageTab/UsageTab"; +import { formatUsageDateRange, toInclusiveDateRange } from "./utils/dateRange"; + +interface SpendPageViewProps { + configData: TypesGen.ChatUsageLimitConfigResponse | undefined; + isLoadingConfig: boolean; + configError: Error | null; + refetchConfig: () => void; + groupsData: TypesGen.Group[] | undefined; + isLoadingGroups: boolean; + groupsError: Error | null; + onUpdateConfig: ( + req: TypesGen.ChatUsageLimitConfig, + options?: { onSuccess?: () => void }, + ) => void; + isUpdatingConfig: boolean; + updateConfigError: Error | null; + 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; + 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; + activeTab: "limits" | "usage"; + onActiveTabChange: (tab: "limits" | "usage") => void; +} + +export const SpendPageView: FC = ({ + configData, + isLoadingConfig, + configError, + refetchConfig, + groupsData, + isLoadingGroups, + groupsError, + onUpdateConfig, + isUpdatingConfig, + updateConfigError, + 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, + activeTab, + onActiveTabChange, +}) => { + 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, + }); + + const displayDateRange = toInclusiveDateRange(dateRange, endDateIsExclusive); + const dateRangeLabel = formatUsageDateRange(dateRange, { + endDateIsExclusive, + }); + + 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(), + }, + { onSuccess: showSavedState }, + ); + }; + + const groupOverrides = configData?.group_overrides ?? []; + const overrides = configData?.overrides ?? []; + const unpricedModelCount = configData?.unpriced_model_count ?? 0; + const groupOrganizationNames = + groupsData?.reduce>((acc, group) => { + acc[group.id] = group.organization_name; + return acc; + }, {}) ?? {}; + const { isSavedVisible, showSavedState } = useTemporarySavedState(); + + if (drillInUserId) { + return ( + + ); + } + return ( + + {(groupCtrl) => ( + + {(userCtrl) => ( +
+ + + Spend limits and usage + + + Configure spend limits and monitor AI usage across your + deployment. + + + + { + if (tab === "limits" || tab === "usage") { + onActiveTabChange(tab); + } + }} + > + + Spend limits + Usage + + + +
+ {isLoadingConfig ? ( +
+ +
+ ) : configError ? ( +
+ + +
+ ) : ( + <> + + {({ + enabled, + onEnabledChange, + period, + onPeriodChange, + amountDollars, + onAmountDollarsChange, + isAmountValid, + isDirty, + saveDefault, + }) => ( + { + handleResetUpdateConfig(); + onEnabledChange(v); + }} + period={period} + onPeriodChange={(v) => { + handleResetUpdateConfig(); + onPeriodChange(v); + }} + amountDollars={amountDollars} + onAmountDollarsChange={(v) => { + handleResetUpdateConfig(); + onAmountDollarsChange(v); + }} + unpricedModelCount={unpricedModelCount} + isSaving={isUpdatingConfig} + isSavedVisible={isSavedVisible} + saveDisabled={ + isUpdatingConfig || !isAmountValid || !isDirty + } + onSave={isDirty ? saveDefault : undefined} + saveStatus={ + isSavedVisible ? ( + + ) : updateConfigError ? ( + getErrorMessage( + updateConfigError, + "Failed to save the default spend limit.", + ) + ) : null + } + /> + )} + + +
+ + { + userCtrl.handleShowUserFormChange(false); + groupCtrl.handleEditGroupOverride(override); + }} + onAddGroupOverride={ + groupCtrl.handleAddGroupOverride + } + onDeleteGroupOverride={onDeleteGroupOverride} + upsertPending={isUpsertingGroupOverride} + upsertError={upsertGroupOverrideError} + deletePending={isDeletingGroupOverride} + deleteError={deleteGroupOverrideError} + groupsError={groupsError} + /> +
+ +
+ + { + groupCtrl.handleShowGroupFormChange(false); + userCtrl.handleEditUserOverride(override); + }} + onAddOverride={userCtrl.handleAddOverride} + onDeleteOverride={onDeleteOverride} + upsertPending={isUpsertingOverride} + upsertError={upsertOverrideError} + deletePending={isDeletingOverride} + deleteError={deleteOverrideError} + /> +
+ + )} +
+
+ + + { + groupCtrl.handleShowGroupFormChange(false); + userCtrl.handleEditUserOverride(override); + }} + /> + +
+
+ )} +
+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/BackButton.tsx b/site/src/pages/AISettingsPage/SpendPage/components/BackButton.tsx similarity index 100% rename from site/src/pages/AgentsPage/components/BackButton.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/BackButton.tsx diff --git a/site/src/pages/AgentsPage/components/ChatCostSummaryView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.stories.tsx similarity index 95% rename from site/src/pages/AgentsPage/components/ChatCostSummaryView.stories.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.stories.tsx index cda89483e6..dbcf32f074 100644 --- a/site/src/pages/AgentsPage/components/ChatCostSummaryView.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.stories.tsx @@ -58,7 +58,7 @@ const emptySummary = buildSummary({ }); const meta: Meta = { - title: "pages/AgentsPage/ChatCostSummaryView", + title: "pages/AISettingsPage/SpendPage/components/ChatCostSummaryView", component: ChatCostSummaryView, args: { summary: undefined, @@ -148,7 +148,7 @@ export const PaginatedChats: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // First page shows agents 1–10, agent 11 is on page 2. + // First page shows agents 1 to 10, agent 11 is on page 2. await canvas.findByText("Agent 1"); await expect(canvas.queryByText("Agent 11")).not.toBeInTheDocument(); @@ -169,7 +169,7 @@ export const PaginatedModels: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // First page shows models 1–10, model 11 is on page 2. + // First page shows models 1 to 10, model 11 is on page 2. await canvas.findByText("Model 1"); await expect(canvas.queryByText("Model 11")).not.toBeInTheDocument(); diff --git a/site/src/pages/AgentsPage/components/ChatCostSummaryView.tsx b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx similarity index 99% rename from site/src/pages/AgentsPage/components/ChatCostSummaryView.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx index 6066768571..8d0ef16076 100644 --- a/site/src/pages/AgentsPage/components/ChatCostSummaryView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx @@ -136,7 +136,7 @@ export const ChatCostSummaryView: FC = ({ )} remaining`; const usageLimitCurrentPeriod = showUsageLimitCard && usageLimit?.period_start && usageLimit?.period_end - ? `Current period: ${dayjs(usageLimit.period_start).format("MMM D")} – ${dayjs( + ? `Current period: ${dayjs(usageLimit.period_start).format("MMM D")} to ${dayjs( usageLimit.period_end, ).format("MMM D")}` : ""; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx similarity index 90% rename from site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitController.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx index 073ab2044f..855a25e0da 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/DefaultLimitController.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx @@ -20,6 +20,7 @@ interface DefaultLimitControllerProps { amountDollars: string; onAmountDollarsChange: (amount: string) => void; isAmountValid: boolean; + isDirty: boolean; saveDefault: () => void; }) => ReactNode; } @@ -37,6 +38,10 @@ export const DefaultLimitController: FC = ({ initialValues.amountDollars, ); const isAmountValid = !enabled || isPositiveFiniteDollarAmount(amountDollars); + const isDirty = + enabled !== initialValues.enabled || + period !== initialValues.period || + amountDollars !== initialValues.amountDollars; const handleSave = () => { if (enabled && !isPositiveFiniteDollarAmount(amountDollars)) { @@ -54,6 +59,7 @@ export const DefaultLimitController: FC = ({ amountDollars, onAmountDollarsChange: setAmountDollars, isAmountValid, + isDirty, saveDefault: handleSave, }); }; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx new file mode 100644 index 0000000000..aad450f8e3 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx @@ -0,0 +1,137 @@ +import { TriangleAlertIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import type { ChatUsageLimitPeriod } from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { Switch } from "#/components/Switch/Switch"; + +interface DefaultLimitSectionProps { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + period: ChatUsageLimitPeriod; + onPeriodChange: (period: ChatUsageLimitPeriod) => void; + amountDollars: string; + onAmountDollarsChange: (amount: string) => void; + unpricedModelCount: number; + isSaving?: boolean; + isSavedVisible?: boolean; + saveDisabled?: boolean; + onSave?: () => void; + saveStatus?: ReactNode; +} + +export const DefaultLimitSection: FC = ({ + enabled, + onEnabledChange, + period, + onPeriodChange, + amountDollars, + onAmountDollarsChange, + unpricedModelCount, + isSaving = false, + isSavedVisible = false, + saveDisabled = false, + onSave, + saveStatus, +}) => { + return ( +
+
+ +
+

+ Spend limit +

+

+ Set a deployment-wide spend cap that applies to all users by + default. When disabled, users have unlimited spending. +

+
+ + $ + onAmountDollarsChange(event.target.value)} + placeholder="0.00" + aria-label="Spend limit amount in dollars" + disabled={!enabled} + /> + + +
+ {(onSave || isSavedVisible || isSaving) && + (isSavedVisible ? ( + saveStatus + ) : ( + + ))} +
+
+ {saveStatus && !isSavedVisible && ( +
{saveStatus}
+ )} +
+
+ + {enabled && unpricedModelCount > 0 && ( +
+ +
+ {unpricedModelCount === 1 + ? "1 enabled model does not have pricing configured." + : `${unpricedModelCount} enabled models do not have pricing configured.`}{" "} + Usage of unpriced models cannot be tracked against the spend limit. +
+
+ )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx new file mode 100644 index 0000000000..0810c10406 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx @@ -0,0 +1,183 @@ +import { CheckIcon } from "lucide-react"; +import type { FC } from "react"; +import { getErrorMessage } from "#/api/errors"; +import type { Group } from "#/api/typesGenerated"; +import { Autocomplete } from "#/components/Autocomplete/Autocomplete"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { getGroupSubtitle } from "#/modules/groups"; +import { isPositiveFiniteDollarAmount } from "#/utils/currency"; +import type { GroupLimitOverrideGroup } from "./GroupLimitsSection"; + +interface GroupLimitDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedGroup: Group | null; + onSelectedGroupChange: (group: Group | null) => void; + groupAmount: string; + onGroupAmountChange: (amount: string) => void; + availableGroups: Group[]; + groupAutocompleteNoOptionsText: string; + groupsLoading: boolean; + editingGroupOverride: GroupLimitOverrideGroup | null; + upsertPending: boolean; + upsertError: Error | null; + onSave: () => void; + groupAutocompleteId: string; + groupAmountId: string; +} + +export const GroupLimitDialog: FC = ({ + open, + onOpenChange, + selectedGroup, + onSelectedGroupChange, + groupAmount, + onGroupAmountChange, + availableGroups, + groupAutocompleteNoOptionsText, + groupsLoading, + editingGroupOverride, + upsertPending, + upsertError, + onSave, + groupAutocompleteId, + groupAmountId, +}) => { + const isEditing = editingGroupOverride !== null; + const saveDisabled = isEditing + ? upsertPending || !isPositiveFiniteDollarAmount(groupAmount) + : upsertPending || + selectedGroup === null || + !isPositiveFiniteDollarAmount(groupAmount); + + return ( + { + if (upsertPending) { + return; + } + + onOpenChange(nextOpen); + if (!nextOpen) { + onSelectedGroupChange(null); + onGroupAmountChange(""); + } + }} + > + + + + {isEditing ? "Update group budget" : "Add group budget"} + + + {isEditing + ? "Update this group's spend limit override." + : "Set a spend limit override for a specific group."} + + + +
+
+ {editingGroupOverride ? ( + <> + +
+ +
+ + ) : ( + <> + + group.id} + getOptionLabel={(group) => group.display_name || group.name} + isOptionEqualToValue={(option, optionValue) => + option.id === optionValue.id + } + renderOption={(option, isSelected) => ( +
+ + {isSelected && } +
+ )} + placeholder="Search groups..." + noOptionsText={groupAutocompleteNoOptionsText} + loading={groupsLoading} + disabled={groupsLoading} + className="w-full" + /> + + )} +
+
+ + onGroupAmountChange(event.target.value)} + placeholder="0.00" + /> +
+ {upsertError && ( +

+ {getErrorMessage(upsertError, "Failed to save group override.")} +

+ )} +
+ + + + + +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx similarity index 83% rename from site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.stories.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx index accd8e5ad7..eb1963020a 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx @@ -68,14 +68,13 @@ const editingGroupOverride = { }; const meta: Meta = { - title: "pages/AgentsPage/LimitsTab/GroupLimitsSection", + title: "pages/AISettingsPage/SpendPage/LimitsTab/GroupLimitsSection", component: GroupLimitsSection, args: { groupOverrides: mockGroupOverrides, groupOrganizationNames: { - "group-1": "acme", - "group-2": "acme", - "group-4": "acme", + "group-1": "Acme", + "group-2": "Acme", }, showGroupForm: false, onShowGroupFormChange: fn(), @@ -123,14 +122,14 @@ export const EditForm: Story = { groupAmount: "10.00", }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - // Save button confirms edit mode is active. + const body = within(canvasElement.ownerDocument.body); + // Update budget button confirms edit mode is active. await expect( - canvas.getByRole("button", { name: /save/i }), + body.getByRole("button", { name: /update budget/i }), ).toBeInTheDocument(); // The editing group name appears in both the table row and the // read-only edit form identity, confirming it was populated. - const nameElements = canvas.getAllByText( + const nameElements = body.getAllByText( editingGroupOverride.group_display_name, ); expect(nameElements.length).toBeGreaterThanOrEqual(2); @@ -141,11 +140,13 @@ export const DeleteGroupOverride: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // Click the Delete button for the first group override. - const deleteButtons = await body.findAllByRole("button", { - name: "Delete", + const actionButtons = await body.findAllByRole("button", { + name: /actions for/i, }); - await userEvent.click(deleteButtons[0]); + await userEvent.click(actionButtons[0]); + await userEvent.click( + await body.findByRole("menuitem", { name: /remove group limit/i }), + ); // The confirmation dialog should appear. const dialog = await body.findByRole("dialog"); @@ -160,11 +161,13 @@ export const DeleteGroupOverrideCancelled: Story = { play: async ({ canvasElement, args }) => { const body = within(canvasElement.ownerDocument.body); - // Click the Delete button for the first group override. - const deleteButtons = await body.findAllByRole("button", { - name: "Delete", + const actionButtons = await body.findAllByRole("button", { + name: /actions for/i, }); - await userEvent.click(deleteButtons[0]); + await userEvent.click(actionButtons[0]); + await userEvent.click( + await body.findByRole("menuitem", { name: /remove group limit/i }), + ); // Cancel the dialog. await body.findByRole("dialog"); diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx new file mode 100644 index 0000000000..b0914b6e6b --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx @@ -0,0 +1,189 @@ +import { PlusIcon } from "lucide-react"; +import { type FC, useId, useState } from "react"; +import { getErrorMessage } from "#/api/errors"; +import type { ChatUsageLimitGroupOverride, Group } from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { ConfirmDeleteDialog } from "#/components/Dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog"; +import { SearchField } from "#/components/SearchField/SearchField"; +import { paginateItems } from "#/utils/paginateItems"; +import { SpendSectionHeader } from "../SpendSectionHeader"; +import { GroupLimitDialog } from "./GroupLimitDialog"; +import { GROUP_LIMITS_PAGE_SIZE, GroupLimitsTable } from "./GroupLimitsTable"; + +export interface GroupLimitOverrideGroup { + group_id: string; + group_display_name: string; + group_name: string; + group_avatar_url: string; + member_count: number; +} + +export type GroupLimitOverride = ChatUsageLimitGroupOverride; + +interface GroupLimitsSectionProps { + hideHeader?: boolean; + groupOverrides: readonly GroupLimitOverride[]; + groupOrganizationNames?: Record; + showGroupForm: boolean; + onShowGroupFormChange: (show: boolean) => void; + selectedGroup: Group | null; + onSelectedGroupChange: (group: Group | null) => void; + groupAmount: string; + onGroupAmountChange: (amount: string) => void; + availableGroups: Group[]; + groupAutocompleteNoOptionsText: string; + groupsLoading: boolean; + editingGroupOverride: GroupLimitOverrideGroup | null; + onEditGroupOverride: (override: GroupLimitOverride) => void; + onAddGroupOverride: () => void; + onDeleteGroupOverride: (groupID: string) => void; + upsertPending: boolean; + upsertError: Error | null; + deletePending: boolean; + deleteError: Error | null; + groupsError: Error | null; +} + +export const GroupLimitsSection: FC = ({ + hideHeader, + groupOverrides, + groupOrganizationNames, + showGroupForm, + onShowGroupFormChange, + selectedGroup, + onSelectedGroupChange, + groupAmount, + onGroupAmountChange, + availableGroups, + groupAutocompleteNoOptionsText, + groupsLoading, + editingGroupOverride, + onEditGroupOverride, + onAddGroupOverride, + onDeleteGroupOverride, + upsertPending, + upsertError, + deletePending, + deleteError, + groupsError, +}) => { + const groupAutocompleteId = useId(); + const groupAmountId = useId(); + const isEditing = editingGroupOverride !== null; + const [pendingDeleteGroupId, setPendingDeleteGroupId] = useState< + string | null + >(null); + const [page, setPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(""); + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const filteredGroupOverrides = normalizedSearchQuery + ? groupOverrides.filter((override) => + [override.group_display_name, override.group_name].some((value) => + value.toLowerCase().includes(normalizedSearchQuery), + ), + ) + : groupOverrides; + const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = + paginateItems(filteredGroupOverrides, GROUP_LIMITS_PAGE_SIZE, page); + + return ( +
+ {!hideHeader && ( + + )} +
+
+ { + setSearchQuery(value); + setPage(1); + }} + placeholder="Search groups..." + aria-label="Search group limits" + className="w-full sm:max-w-md" + /> + +
+ + + + {deleteError && ( +

+ {getErrorMessage(deleteError, "Failed to delete group override.")} +

+ )} + + + {groupsError && ( +

+ {getErrorMessage(groupsError, "Failed to load groups.")} +

+ )} +
+ {pendingDeleteGroupId && ( + { + void onDeleteGroupOverride(pendingDeleteGroupId); + setPendingDeleteGroupId(null); + }} + isPending={deletePending} + open + onOpenChange={(open) => !open && setPendingDeleteGroupId(null)} + /> + )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx new file mode 100644 index 0000000000..56ff608434 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx @@ -0,0 +1,167 @@ +import { EllipsisVerticalIcon, TrashIcon } from "lucide-react"; +import type { FC } from "react"; +import { Link } from "react-router"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount"; +import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { formatCostMicros } from "#/utils/currency"; +import type { GroupLimitOverride } from "./GroupLimitsSection"; + +export const GROUP_LIMITS_PAGE_SIZE = 10; + +interface GroupLimitsTableProps { + pagedOverrides: readonly GroupLimitOverride[]; + totalOverrides: number; + clampedPage: number; + hasPreviousPage: boolean; + hasNextPage: boolean; + onPageChange: (page: number) => void; + onEditGroupOverride: (override: GroupLimitOverride) => void; + onRequestDelete: (groupID: string) => void; + groupOrganizationNames?: Record; + deletePending: boolean; + upsertPending: boolean; + isEditing: boolean; + emptyMessage: string; +} + +export const GroupLimitsTable: FC = ({ + pagedOverrides, + totalOverrides, + clampedPage, + hasPreviousPage, + hasNextPage, + onPageChange, + onEditGroupOverride, + onRequestDelete, + groupOrganizationNames, + deletePending, + upsertPending, + isEditing, + emptyMessage, +}) => { + if (totalOverrides === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( + <> +
+ + + + Group + Members + Spend limit + Actions + + + + {pagedOverrides.map((override) => { + const orgName = groupOrganizationNames?.[override.group_id]; + const groupAvatar = ( + + ); + + return ( + + + {orgName ? ( + + {groupAvatar} + + ) : ( + groupAvatar + )} + + {override.member_count} + + {override.spend_limit_micros !== null + ? formatCostMicros(override.spend_limit_micros) + : "Unlimited"} + + + + + + + + onEditGroupOverride(override)} + > + Update budget + + onRequestDelete(override.group_id)} + > + + Remove group limit + + + + + + ); + })} + +
+ +
+ {totalOverrides > GROUP_LIMITS_PAGE_SIZE && ( + + )} + + ); +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/GroupOverrideController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx similarity index 94% rename from site/src/pages/AgentsPage/components/LimitsTab/GroupOverrideController.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx index b65e639231..2ccd1ae44f 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/GroupOverrideController.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx @@ -37,7 +37,6 @@ type GroupOverrideChildProps = { existingGroupIds: Set; availableGroups: Group[]; groupAutocompleteNoOptionsText: string; - groupOrganizationNames: Record; }; interface GroupOverrideControllerProps { @@ -68,10 +67,6 @@ export const GroupOverrideController: FC = ({ // Derived values. const existingGroupIds = new Set(groupOverrides.map((g) => g.group_id)); const availableGroups = groups.filter((g) => !existingGroupIds.has(g.id)); - const groupOrganizationNames: Record = {}; - for (const g of groups) { - groupOrganizationNames[g.id] = g.organization_name; - } const groupAutocompleteNoOptionsText = isLoadingGroups ? "Loading groups..." : groups.length === 0 @@ -142,6 +137,5 @@ export const GroupOverrideController: FC = ({ existingGroupIds, availableGroups, groupAutocompleteNoOptionsText, - groupOrganizationNames, }); }; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/UserOverrideController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx similarity index 100% rename from site/src/pages/AgentsPage/components/LimitsTab/UserOverrideController.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx new file mode 100644 index 0000000000..1f900dcf11 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx @@ -0,0 +1,157 @@ +import type { FC } from "react"; +import { getErrorMessage } from "#/api/errors"; +import type { User } from "#/api/typesGenerated"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { UserAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; +import { isPositiveFiniteDollarAmount } from "#/utils/currency"; +import type { UserOverrideUser } from "./UserOverridesSection"; + +interface UserOverrideDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedUser: User | null; + onSelectedUserChange: (user: User | null) => void; + userOverrideAmount: string; + onUserOverrideAmountChange: (amount: string) => void; + selectedUserAlreadyOverridden: boolean; + editingUserOverride: UserOverrideUser | null; + upsertPending: boolean; + upsertError: Error | null; + onSave: () => void; + amountInputId: string; +} + +export const UserOverrideDialog: FC = ({ + open, + onOpenChange, + selectedUser, + onSelectedUserChange, + userOverrideAmount, + onUserOverrideAmountChange, + selectedUserAlreadyOverridden, + editingUserOverride, + upsertPending, + upsertError, + onSave, + amountInputId, +}) => { + const isEditing = editingUserOverride !== null; + const saveDisabled = isEditing + ? upsertPending || !isPositiveFiniteDollarAmount(userOverrideAmount) + : upsertPending || + !selectedUser || + selectedUserAlreadyOverridden || + !isPositiveFiniteDollarAmount(userOverrideAmount); + + return ( + { + if (upsertPending) { + return; + } + + onOpenChange(nextOpen); + if (!nextOpen) { + onSelectedUserChange(null); + onUserOverrideAmountChange(""); + } + }} + > + + + + {isEditing ? "Update user budget" : "Add user budget"} + + + {isEditing + ? "Update this user's spend limit override." + : "Set a spend limit override for a specific user."} + + + +
+
+ {editingUserOverride ? ( + <> + +
+ +
+ + ) : ( + + )} +
+
+ + + onUserOverrideAmountChange(event.target.value) + } + placeholder="0.00" + /> +
+ {!isEditing && selectedUserAlreadyOverridden && ( +

+ This user already has an override. +

+ )} + {upsertError && ( +

+ {getErrorMessage(upsertError, "Failed to save the override.")} +

+ )} +
+ + + + + +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx similarity index 81% rename from site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.stories.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx index c12185a558..7486a749c9 100644 --- a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx @@ -38,7 +38,7 @@ const mockOverrides = [ ]; const meta: Meta = { - title: "pages/AgentsPage/LimitsTab/UserOverridesSection", + title: "pages/AISettingsPage/SpendPage/LimitsTab/UserOverridesSection", component: UserOverridesSection, args: { overrides: mockOverrides, @@ -97,14 +97,14 @@ export const EditForm: Story = { userOverrideAmount: "5.00", }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - // Save button confirms edit mode is active. + const body = within(canvasElement.ownerDocument.body); + // Update budget button confirms edit mode is active. await expect( - canvas.getByRole("button", { name: /save/i }), + body.getByRole("button", { name: /update budget/i }), ).toBeInTheDocument(); // The editing user name appears in both the table row and the // read-only edit form identity, confirming it was populated. - const nameElements = canvas.getAllByText("Alice Johnson"); + const nameElements = body.getAllByText("Alice Johnson"); expect(nameElements.length).toBeGreaterThanOrEqual(2); }, }; @@ -113,11 +113,13 @@ export const DeleteUserOverride: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // Click the Delete button for the first user override. - const deleteButtons = await body.findAllByRole("button", { - name: "Delete", + const actionButtons = await body.findAllByRole("button", { + name: /actions for/i, }); - await userEvent.click(deleteButtons[0]); + await userEvent.click(actionButtons[0]); + await userEvent.click( + await body.findByRole("menuitem", { name: /remove override/i }), + ); // The confirmation dialog should appear. const dialog = await body.findByRole("dialog"); @@ -132,11 +134,13 @@ export const DeleteUserOverrideCancelled: Story = { play: async ({ canvasElement, args }) => { const body = within(canvasElement.ownerDocument.body); - // Click the Delete button for the first user override. - const deleteButtons = await body.findAllByRole("button", { - name: "Delete", + const actionButtons = await body.findAllByRole("button", { + name: /actions for/i, }); - await userEvent.click(deleteButtons[0]); + await userEvent.click(actionButtons[0]); + await userEvent.click( + await body.findByRole("menuitem", { name: /remove override/i }), + ); // Cancel the dialog. await body.findByRole("dialog"); diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx new file mode 100644 index 0000000000..15b8c4832e --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx @@ -0,0 +1,170 @@ +import { PlusIcon } from "lucide-react"; +import { type FC, useId, useState } from "react"; +import { getErrorMessage } from "#/api/errors"; +import type { User } from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { ConfirmDeleteDialog } from "#/components/Dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog"; +import { SearchField } from "#/components/SearchField/SearchField"; +import { paginateItems } from "#/utils/paginateItems"; +import { SpendSectionHeader } from "../SpendSectionHeader"; +import { UserOverrideDialog } from "./UserOverrideDialog"; +import { + USER_OVERRIDES_PAGE_SIZE, + UserOverridesTable, +} from "./UserOverridesTable"; + +export interface UserOverrideUser { + user_id: string; + name: string; + username: string; + avatar_url: string; +} + +export interface UserOverride extends UserOverrideUser { + spend_limit_micros: number | null; +} + +interface UserOverridesSectionProps { + hideHeader?: boolean; + overrides: readonly UserOverride[]; + showUserForm: boolean; + onShowUserFormChange: (show: boolean) => void; + selectedUser: User | null; + onSelectedUserChange: (user: User | null) => void; + userOverrideAmount: string; + onUserOverrideAmountChange: (amount: string) => void; + selectedUserAlreadyOverridden: boolean; + editingUserOverride: UserOverrideUser | null; + onEditUserOverride: (override: UserOverride) => void; + onAddOverride: () => void; + onDeleteOverride: (userID: string) => void; + upsertPending: boolean; + upsertError: Error | null; + deletePending: boolean; + deleteError: Error | null; +} + +export const UserOverridesSection: FC = ({ + hideHeader, + overrides, + showUserForm, + onShowUserFormChange, + selectedUser, + onSelectedUserChange, + userOverrideAmount, + onUserOverrideAmountChange, + selectedUserAlreadyOverridden, + editingUserOverride, + onEditUserOverride, + onAddOverride, + onDeleteOverride, + upsertPending, + upsertError, + deletePending, + deleteError, +}) => { + const userOverrideAmountId = useId(); + const isEditing = editingUserOverride !== null; + const [pendingDeleteUserId, setPendingDeleteUserId] = useState( + null, + ); + const [page, setPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(""); + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const filteredOverrides = normalizedSearchQuery + ? overrides.filter((override) => + [override.name, override.username].some((value) => + value.toLowerCase().includes(normalizedSearchQuery), + ), + ) + : overrides; + const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = + paginateItems(filteredOverrides, USER_OVERRIDES_PAGE_SIZE, page); + + return ( +
+ {!hideHeader && ( + + )} +
+
+ { + setSearchQuery(value); + setPage(1); + }} + placeholder="Search users..." + aria-label="Search user overrides" + className="w-full sm:max-w-md" + /> + +
+ + + + {deleteError && ( +

+ {getErrorMessage(deleteError, "Failed to delete override.")} +

+ )} + + +
+ {pendingDeleteUserId && ( + { + void onDeleteOverride(pendingDeleteUserId); + setPendingDeleteUserId(null); + }} + isPending={deletePending} + open + onOpenChange={(open) => !open && setPendingDeleteUserId(null)} + /> + )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx new file mode 100644 index 0000000000..1ab125f2c4 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx @@ -0,0 +1,144 @@ +import { EllipsisVerticalIcon, TrashIcon } from "lucide-react"; +import type { FC } from "react"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount"; +import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { formatCostMicros } from "#/utils/currency"; +import type { UserOverride } from "./UserOverridesSection"; + +export const USER_OVERRIDES_PAGE_SIZE = 10; + +interface UserOverridesTableProps { + pagedOverrides: readonly UserOverride[]; + totalOverrides: number; + clampedPage: number; + hasPreviousPage: boolean; + hasNextPage: boolean; + onPageChange: (page: number) => void; + onEditUserOverride: (override: UserOverride) => void; + onRequestDelete: (userID: string) => void; + deletePending: boolean; + upsertPending: boolean; + isEditing: boolean; + emptyMessage: string; +} + +export const UserOverridesTable: FC = ({ + pagedOverrides, + totalOverrides, + clampedPage, + hasPreviousPage, + hasNextPage, + onPageChange, + onEditUserOverride, + onRequestDelete, + deletePending, + upsertPending, + isEditing, + emptyMessage, +}) => { + if (totalOverrides === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( + <> +
+ + + + User + Spend limit + Actions + + + + {pagedOverrides.map((override) => ( + + + + + + {override.spend_limit_micros !== null + ? formatCostMicros(override.spend_limit_micros) + : "Unlimited"} + + + + + + + + onEditUserOverride(override)} + > + Update budget + + onRequestDelete(override.user_id)} + > + + Remove override + + + + + + ))} + +
+ +
+ {totalOverrides > USER_OVERRIDES_PAGE_SIZE && ( + + )} + + ); +}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/limitsFormLogic.test.ts b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts similarity index 100% rename from site/src/pages/AgentsPage/components/LimitsTab/limitsFormLogic.test.ts rename to site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts diff --git a/site/src/pages/AgentsPage/components/LimitsTab/limitsFormLogic.ts b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts similarity index 100% rename from site/src/pages/AgentsPage/components/LimitsTab/limitsFormLogic.ts rename to site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts diff --git a/site/src/pages/AgentsPage/components/SpendDrillInView.tsx b/site/src/pages/AISettingsPage/SpendPage/components/SpendDrillInView.tsx similarity index 96% rename from site/src/pages/AgentsPage/components/SpendDrillInView.tsx rename to site/src/pages/AISettingsPage/SpendPage/components/SpendDrillInView.tsx index 79aed263a2..d14040d6a7 100644 --- a/site/src/pages/AgentsPage/components/SpendDrillInView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/SpendDrillInView.tsx @@ -11,7 +11,7 @@ import { import { Spinner } from "#/components/Spinner/Spinner"; import { BackButton } from "./BackButton"; import { ChatCostSummaryView } from "./ChatCostSummaryView"; -import { SectionHeader } from "./SectionHeader"; +import { SpendSectionHeader } from "./SpendSectionHeader"; interface SpendDrillInViewProps { selectedUser: TypesGen.User | null; @@ -47,10 +47,10 @@ export const SpendDrillInView: FC = ({ const backButton = ; const header = ( - = ({ + title, + description, + actions, +}) => { + return ( +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {actions} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx b/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx new file mode 100644 index 0000000000..c3d5f63460 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx @@ -0,0 +1,264 @@ +import { EllipsisVerticalIcon } from "lucide-react"; +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { + DateRangePicker, + type DateRangeValue, +} from "#/components/DateRangePicker/DateRangePicker"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +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 { formatCostMicros } from "#/utils/currency"; +import { SpendSectionHeader } from "../SpendSectionHeader"; + +type UsageUserOverride = { + user_id: string; + name: string; + username: string; + avatar_url: string; + spend_limit_micros: number | null; +}; + +interface UsageTabProps { + displayDateRange: DateRangeValue; + onDateRangeChange: (value: DateRangeValue) => void; + searchFilter: string; + onSearchFilterChange: (value: string) => void; + usersQuery: PaginationResult & { + data: TypesGen.ChatCostUsersResponse | undefined; + isLoading: boolean; + isFetching: boolean; + error: unknown; + refetch: () => unknown; + }; + overrides: readonly UsageUserOverride[]; + onSelectUser: (user: TypesGen.ChatCostUserRollup) => void; + onEditBudget: (override: UsageUserOverride) => void; +} + +export const UsageTab: FC = ({ + displayDateRange, + onDateRangeChange, + searchFilter, + onSearchFilterChange, + usersQuery, + overrides, + onSelectUser, + onEditBudget, +}) => { + return ( +
+ + } + /> +
+
+ +
+
+ {usersQuery.isLoading && ( +
+ +
+ )} + {usersQuery.error != null && ( +
+ + +
+ )} + {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 + Actions + + + + {usersQuery.data.users.map((user) => ( + { + const override = overrides.find( + (o) => o.user_id === selectedUser.user_id, + ) ?? { + user_id: selectedUser.user_id, + name: selectedUser.name, + username: selectedUser.username, + avatar_url: selectedUser.avatar_url, + spend_limit_micros: null, + }; + onEditBudget(override); + }} + /> + ))} + +
+
+
+ )} +
+ )} +
+ ); +}; + +const UserRow: FC<{ + user: TypesGen.ChatCostUserRollup; + onSelect: (user: TypesGen.ChatCostUserRollup) => void; + onEditBudget: (user: TypesGen.ChatCostUserRollup) => void; +}> = ({ user, onSelect, onEditBudget }) => { + 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)} + + event.stopPropagation()}> + + + + + + onEditBudget(user)}> + Update budget + + onSelect(user)}> + View spend details + + + + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/utils/dateRange.test.ts b/site/src/pages/AISettingsPage/SpendPage/utils/dateRange.test.ts similarity index 91% rename from site/src/pages/AgentsPage/utils/dateRange.test.ts rename to site/src/pages/AISettingsPage/SpendPage/utils/dateRange.test.ts index 63b5f4f50a..512ac74613 100644 --- a/site/src/pages/AgentsPage/utils/dateRange.test.ts +++ b/site/src/pages/AISettingsPage/SpendPage/utils/dateRange.test.ts @@ -67,7 +67,7 @@ describe("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"); + expect(result).toBe("Jun 1 to Jun 8, 2025"); }); it("shows previous day when endDateIsExclusive is true and end date is midnight", () => { @@ -78,7 +78,7 @@ describe("formatUsageDateRange", () => { }, { endDateIsExclusive: true }, ); - expect(result).toBe("Jun 1 – Jun 7, 2025"); + expect(result).toBe("Jun 1 to Jun 7, 2025"); }); it("shows same day when endDateIsExclusive is true and end date is not midnight", () => { @@ -89,7 +89,7 @@ describe("formatUsageDateRange", () => { }, { endDateIsExclusive: true }, ); - expect(result).toBe("Jun 1 – Jun 8, 2025"); + expect(result).toBe("Jun 1 to Jun 8, 2025"); }); it("shows same day when endDateIsExclusive is false and end date is midnight", () => { @@ -100,7 +100,7 @@ describe("formatUsageDateRange", () => { }, { endDateIsExclusive: false }, ); - expect(result).toBe("Jun 1 – Jun 8, 2025"); + expect(result).toBe("Jun 1 to Jun 8, 2025"); }); it("formats a cross-month range", () => { @@ -108,7 +108,7 @@ describe("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"); + expect(result).toBe("May 28 to Jun 4, 2025"); }); it("formats a same-month range", () => { @@ -116,7 +116,7 @@ describe("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"); + expect(result).toBe("Jun 1 to Jun 15, 2025"); }); it("formats a cross-year range without ambiguity", () => { @@ -125,8 +125,8 @@ describe("formatUsageDateRange", () => { 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 + // as "Dec 28 to 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"); + expect(result).toBe("Dec 28 to Jan 4, 2026"); }); }); diff --git a/site/src/pages/AgentsPage/utils/dateRange.ts b/site/src/pages/AISettingsPage/SpendPage/utils/dateRange.ts similarity index 95% rename from site/src/pages/AgentsPage/utils/dateRange.ts rename to site/src/pages/AISettingsPage/SpendPage/utils/dateRange.ts index 6406323748..0bde5a85f3 100644 --- a/site/src/pages/AgentsPage/utils/dateRange.ts +++ b/site/src/pages/AISettingsPage/SpendPage/utils/dateRange.ts @@ -48,7 +48,7 @@ export function formatUsageDateRange( options?.endDateIsExclusive ?? false, ); - return `${dayjs(adjusted.startDate).format("MMM D")} – ${dayjs( + return `${dayjs(adjusted.startDate).format("MMM D")} to ${dayjs( adjusted.endDate, ).format("MMM D, YYYY")}`; } diff --git a/site/src/pages/AgentsPage/AgentAnalyticsPageView.tsx b/site/src/pages/AgentsPage/AgentAnalyticsPageView.tsx index e7e7d5c36a..99195e9e76 100644 --- a/site/src/pages/AgentsPage/AgentAnalyticsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentAnalyticsPageView.tsx @@ -1,7 +1,7 @@ import { BarChart3Icon } from "lucide-react"; import type { FC } from "react"; import type { ChatCostSummary } from "#/api/typesGenerated"; -import { ChatCostSummaryView } from "./components/ChatCostSummaryView"; +import { ChatCostSummaryView } from "#/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView"; import { SectionHeader } from "./components/SectionHeader"; interface AgentAnalyticsPageViewProps { diff --git a/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx deleted file mode 100644 index b9e8b97dab..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsSpendPageView.tsx +++ /dev/null @@ -1,610 +0,0 @@ -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 { - 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/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 236f0ad795..298c1f856c 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -34,7 +34,6 @@ import AgentSettingsCompactionPage from "./AgentSettingsCompactionPage"; import AgentSettingsExperimentsPage from "./AgentSettingsExperimentsPage"; import AgentSettingsGeneralPage from "./AgentSettingsGeneralPage"; import AgentSettingsPage from "./AgentSettingsPage"; -import AgentSettingsSpendPage from "./AgentSettingsSpendPage"; import { type AgentsOutletContext, AgentsPageView } from "./AgentsPageView"; import type { ModelSelectorOption } from "./components/ChatElements"; import { @@ -215,10 +214,13 @@ const agentsRouting = { }, { path: "admin", element: }, { path: "agents", element: }, - { path: "spend", element: }, + { + path: "spend", + element: , + }, { path: "usage", - element: , + element: , }, ], }, @@ -228,6 +230,11 @@ const agentsRouting = { ], }; +const aiSettingsRouting = { + path: "/ai/settings/spend", + element:
Spend limits and usage
, +}; + const setInnerWidthForStory = (width: number) => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "innerWidth"); Object.defineProperty(globalThis, "innerWidth", { @@ -354,7 +361,7 @@ const meta: Meta = { permissions: MockPermissions, reactRouter: reactRouterParameters({ location: { path: "/agents" }, - routing: agentsRouting, + routing: [agentsRouting, aiSettingsRouting], }), }, args: defaultArgs, @@ -1011,7 +1018,7 @@ export const WithAgentSelected: Story = { path: "/agents/chat-1", pathParams: { agentId: "chat-1" }, }, - routing: agentsRouting, + routing: [agentsRouting, aiSettingsRouting], }), }, }; @@ -1063,7 +1070,7 @@ export const OpensAnalyticsForAdmins: Story = { parameters: { reactRouter: reactRouterParameters({ location: { path: "/agents/analytics" }, - routing: agentsRouting, + routing: [agentsRouting, aiSettingsRouting], }), }, play: async () => { @@ -1085,7 +1092,7 @@ export const OpensAnalyticsForNonAdmins: Story = { permissions: MockNoPermissions, reactRouter: reactRouterParameters({ location: { path: "/agents/analytics" }, - routing: agentsRouting, + routing: [agentsRouting, aiSettingsRouting], }), }, play: async () => { @@ -1144,7 +1151,7 @@ export const OpensAdminSubPanelOnMobile: Story = { viewport: { defaultViewport: "mobile1" }, reactRouter: reactRouterParameters({ location: { path: "/agents/settings" }, - routing: agentsRouting, + routing: [agentsRouting, aiSettingsRouting], }), }, play: async () => { @@ -1161,7 +1168,7 @@ export const OpensAdminSubPanelOnMobile: Story = { }, }; -export const SettingsViewResets: Story = { +export const SettingsViewSpendCrossLink: Story = { args: { isAgentsAdmin: true, }, @@ -1175,33 +1182,13 @@ export const SettingsViewResets: Story = { ).toBeInTheDocument(); }); - // Navigate to the admin panel, then open the Spend section. - await userEvent.click(screen.getByRole("link", { name: "Manage agents" })); + await userEvent.click( + await screen.findByRole("link", { name: "Manage agents" }), + ); + await userEvent.click(await screen.findByRole("link", { name: "Spend" })); await waitFor(() => { - expect( - screen.getByText( - "Configure spend limits and monitor usage across your deployment.", - ), - ).toBeInTheDocument(); - }); - - // Step back to the top-level settings panel, then back to conversations. - const backToSettingsButton = await screen.findByRole("link", { - name: "Back to settings", - }); - await userEvent.click(backToSettingsButton); - const backToAgentsButton = await screen.findByRole("link", { - name: "Back to agents", - }); - await userEvent.click(backToAgentsButton); - - // Re-open settings, should reset to General - await openSettingsView(canvasElement); - await waitFor(() => { - expect( - screen.getByText("Personal preferences for your chat experience."), - ).toBeInTheDocument(); + expect(screen.getByText("Spend limits and usage")).toBeInTheDocument(); }); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx index 463f98d2f3..24936e2394 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx @@ -194,9 +194,9 @@ export const SettingsPanel: FC = ({ void; - period: ChatUsageLimitPeriod; - onPeriodChange: (period: ChatUsageLimitPeriod) => void; - amountDollars: string; - onAmountDollarsChange: (amount: string) => void; - unpricedModelCount: number; - adminBadge: ReactNode; - hideHeader?: boolean; -} - -export const DefaultLimitSection: FC = ({ - enabled, - onEnabledChange, - period, - onPeriodChange, - amountDollars, - onAmountDollarsChange, - unpricedModelCount, - adminBadge, - hideHeader, -}) => { - const periodId = useId(); - const amountId = useId(); - - return ( -
- {!hideHeader && ( - - )} - -
-
-
-

- Enable spend limit -

-

- When disabled, users have unlimited spending. -

-
- -
- - {enabled && ( -
-
-
- - - - - - - - Only one period can be active at a time. Spend is - calculated from the start of the current period. - - - -
- -
-
- - onAmountDollarsChange(event.target.value)} - placeholder="0.00" - /> -
-
- )} -
- - {enabled && unpricedModelCount > 0 && ( -
- -
- {unpricedModelCount === 1 - ? "1 enabled model does not have pricing configured." - : `${unpricedModelCount} enabled models do not have pricing configured.`}{" "} - Usage of unpriced models cannot be tracked against the spend limit. -
-
- )} -
- ); -}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx b/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx deleted file mode 100644 index 1e1fa6d475..0000000000 --- a/site/src/pages/AgentsPage/components/LimitsTab/GroupLimitsSection.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import { type FC, useId, useState } from "react"; -import { Link } from "react-router"; -import { getErrorMessage } from "#/api/errors"; -import type { ChatUsageLimitGroupOverride, Group } from "#/api/typesGenerated"; -import { Autocomplete } from "#/components/Autocomplete/Autocomplete"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { ConfirmDeleteDialog } from "#/components/Dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog"; -import { Input } from "#/components/Input/Input"; -import { Label } from "#/components/Label/Label"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; -import { getGroupSubtitle } from "#/modules/groups"; -import { - formatCostMicros, - isPositiveFiniteDollarAmount, -} from "#/utils/currency"; -import { SectionHeader } from "../SectionHeader"; - -interface GroupLimitsSectionProps { - hideHeader?: boolean; - groupOverrides: readonly ChatUsageLimitGroupOverride[]; - /** Maps group_id → organization_name for building links to the group page. */ - groupOrganizationNames?: Record; - showGroupForm: boolean; - onShowGroupFormChange: (show: boolean) => void; - selectedGroup: Group | null; - onSelectedGroupChange: (group: Group | null) => void; - groupAmount: string; - onGroupAmountChange: (amount: string) => void; - availableGroups: Group[]; - groupAutocompleteNoOptionsText: string; - groupsLoading: boolean; - editingGroupOverride: { - group_id: string; - group_display_name: string; - group_name: string; - group_avatar_url: string; - member_count: number; - } | null; - onEditGroupOverride: ( - override: GroupLimitsSectionProps["groupOverrides"][number], - ) => void; - onAddGroupOverride: () => void; - onDeleteGroupOverride: (groupID: string) => void; - upsertPending: boolean; - upsertError: Error | null; - deletePending: boolean; - deleteError: Error | null; - groupsError: Error | null; -} - -export const GroupLimitsSection: FC = ({ - hideHeader, - groupOverrides, - groupOrganizationNames, - showGroupForm, - onShowGroupFormChange, - selectedGroup, - onSelectedGroupChange, - groupAmount, - onGroupAmountChange, - availableGroups, - groupAutocompleteNoOptionsText, - groupsLoading, - editingGroupOverride, - onEditGroupOverride, - onAddGroupOverride, - onDeleteGroupOverride, - upsertPending, - upsertError, - deletePending, - deleteError, - groupsError, -}) => { - const groupAutocompleteId = useId(); - const groupAmountId = useId(); - const isEditing = editingGroupOverride !== null; - const [pendingDeleteGroupId, setPendingDeleteGroupId] = useState< - string | null - >(null); - - return ( -
- {!hideHeader && ( - - )} -
- {groupOverrides.length > 0 ? ( - - - - Group - Members - Spend limit - Actions - - - - {groupOverrides.map((override) => { - const orgName = groupOrganizationNames?.[override.group_id]; - const groupLink = orgName - ? `/organizations/${orgName}/groups/${override.group_name}` - : undefined; - - const avatar = ( - - ); - - return ( - - - {groupLink ? ( - - {avatar} - - ) : ( - avatar - )} - - {override.member_count} - - {override.spend_limit_micros !== null - ? formatCostMicros(override.spend_limit_micros) - : "Unlimited"} - - -
- - -
-
-
- ); - })} -
-
- ) : ( -
- No group overrides configured. -
- )} - - {deleteError && ( -

- {getErrorMessage(deleteError, "Failed to delete group override.")} -

- )} - - {!showGroupForm ? ( - - ) : ( -
-
-
- {editingGroupOverride ? ( - <> - -
- -
- - ) : ( - <> - - group.id} - getOptionLabel={(group) => - group.display_name || group.name - } - isOptionEqualToValue={(option, optionValue) => - option.id === optionValue.id - } - renderOption={(option, isSelected) => ( -
- - {isSelected && ( - - )} -
- )} - placeholder="Search groups..." - noOptionsText={groupAutocompleteNoOptionsText} - loading={groupsLoading} - disabled={groupsLoading} - className="w-full" - /> - - )} -
-
- - onGroupAmountChange(event.target.value)} - placeholder="0.00" - /> -
-
- - -
-
-
- )} - {upsertError && ( -

- {getErrorMessage(upsertError, "Failed to save group override.")} -

- )} - {groupsError && ( -

- {getErrorMessage(groupsError, "Failed to load groups.")} -

- )} -
- {pendingDeleteGroupId && ( - { - void onDeleteGroupOverride(pendingDeleteGroupId); - setPendingDeleteGroupId(null); - }} - isPending={deletePending} - open - onOpenChange={(open) => !open && setPendingDeleteGroupId(null)} - /> - )}{" "} -
- ); -}; diff --git a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx b/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx deleted file mode 100644 index 4760267c94..0000000000 --- a/site/src/pages/AgentsPage/components/LimitsTab/UserOverridesSection.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { type FC, useId, useState } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { User } from "#/api/typesGenerated"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { ConfirmDeleteDialog } from "#/components/Dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog"; -import { Input } from "#/components/Input/Input"; -import { Label } from "#/components/Label/Label"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; -import { UserAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; -import { - formatCostMicros, - isPositiveFiniteDollarAmount, -} from "#/utils/currency"; -import { SectionHeader } from "../SectionHeader"; - -interface UserOverridesSectionProps { - hideHeader?: boolean; - overrides: ReadonlyArray<{ - user_id: string; - name: string; - username: string; - avatar_url: string; - spend_limit_micros: number | null; - }>; - showUserForm: boolean; - onShowUserFormChange: (show: boolean) => void; - selectedUser: User | null; - onSelectedUserChange: (user: User | null) => void; - userOverrideAmount: string; - onUserOverrideAmountChange: (amount: string) => void; - selectedUserAlreadyOverridden: boolean; - editingUserOverride: { - user_id: string; - name: string; - username: string; - avatar_url: string; - } | null; - onEditUserOverride: ( - override: UserOverridesSectionProps["overrides"][number], - ) => void; - onAddOverride: () => void; - onDeleteOverride: (userID: string) => void; - upsertPending: boolean; - upsertError: Error | null; - deletePending: boolean; - deleteError: Error | null; -} - -export const UserOverridesSection: FC = ({ - hideHeader, - overrides, - showUserForm, - onShowUserFormChange, - selectedUser, - onSelectedUserChange, - userOverrideAmount, - onUserOverrideAmountChange, - selectedUserAlreadyOverridden, - editingUserOverride, - onEditUserOverride, - onAddOverride, - onDeleteOverride, - upsertPending, - upsertError, - deletePending, - deleteError, -}) => { - const userOverrideAmountId = useId(); - const isEditing = editingUserOverride !== null; - const [pendingDeleteUserId, setPendingDeleteUserId] = useState( - null, - ); - - return ( -
- {!hideHeader && ( - - )} -
- {overrides.length > 0 ? ( - - - - User - Spend limit - Actions - - - - {overrides.map((override) => ( - - - - - - {override.spend_limit_micros !== null - ? formatCostMicros(override.spend_limit_micros) - : "Unlimited"} - - -
- - -
-
-
- ))} -
-
- ) : ( -
- No overrides configured. -
- )} - - {deleteError && ( -

- {getErrorMessage(deleteError, "Failed to delete override.")} -

- )} - - {!showUserForm ? ( - - ) : ( -
-
-
- {editingUserOverride ? ( - <> - -
- -
- - ) : ( - - )} -
-
- - - onUserOverrideAmountChange(event.target.value) - } - placeholder="0.00" - /> -
-
- - -
-
-
- )} - {!isEditing && selectedUserAlreadyOverridden && ( -

- This user already has an override. -

- )} - {upsertError && ( -

- {getErrorMessage(upsertError, "Failed to save the override.")} -

- )} -
- {pendingDeleteUserId && ( - { - void onDeleteOverride(pendingDeleteUserId); - setPendingDeleteUserId(null); - }} - isPending={deletePending} - open - onOpenChange={(open) => !open && setPendingDeleteUserId(null)} - /> - )}{" "} -
- ); -}; diff --git a/site/src/pages/AgentsPage/components/SectionHeader.tsx b/site/src/pages/AgentsPage/components/SectionHeader.tsx index fa0608f21b..1cf0159847 100644 --- a/site/src/pages/AgentsPage/components/SectionHeader.tsx +++ b/site/src/pages/AgentsPage/components/SectionHeader.tsx @@ -5,8 +5,6 @@ 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"; } diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 8d721650c1..a44f12b6ef 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -24,6 +24,7 @@ import { getDefaultOrganizationName, useDashboard, } from "#/modules/dashboard/useDashboard"; +import { getUsageLimitPeriodLabel } from "#/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView"; import { getSeverity, severityTextClassName, @@ -31,7 +32,6 @@ import { } from "#/utils/budget"; import { cn } from "#/utils/cn"; import { formatCostMicros } from "#/utils/currency"; -import { getUsageLimitPeriodLabel } from "./ChatCostSummaryView"; import { SvgRingProgress } from "./SvgRingProgress"; type UsageSectionData = { diff --git a/site/src/router.tsx b/site/src/router.tsx index 4b2cdd7b39..558b1791fa 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -390,8 +390,8 @@ const AgentSettingsPersonalSkillsPage = lazy( const AgentSettingsAPIKeysPage = lazy( () => import("./pages/AgentsPage/AgentSettingsAPIKeysPage"), ); -const AgentSettingsSpendPage = lazy( - () => import("./pages/AgentsPage/AgentSettingsSpendPage"), +const AISettingsSpendPage = lazy( + () => import("./pages/AISettingsPage/SpendPage/SpendPage"), ); const AgentAnalyticsPage = lazy( () => import("./pages/AgentsPage/AgentAnalyticsPage"), @@ -769,6 +769,7 @@ export const router = createBrowserRouter( /> } /> } /> + } /> } @@ -893,9 +894,18 @@ export const router = createBrowserRouter( path="mcp-servers" element={} /> - } /> - } /> - } /> + } + /> + } + /> + } + /> }