feat(site): move spend page to AI settings (#26800)

This commit is contained in:
Danielle Maywood
2026-06-29 12:49:38 +01:00
committed by GitHub
parent 60a56cf343
commit cf1c29b651
36 changed files with 2074 additions and 1517 deletions
@@ -35,6 +35,9 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/models">Models</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/spend">Spend</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/instructions">
Instructions
@@ -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<AgentSettingsSpendPageProps> = ({ now }) => {
const SpendPage: FC<SpendPageProps> = ({ 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<AgentSettingsSpendPageProps> = ({ 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<AgentSettingsSpendPageProps> = ({ 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<AgentSettingsSpendPageProps> = ({ 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<AgentSettingsSpendPageProps> = ({ 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<AgentSettingsSpendPageProps> = ({ now }) => {
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsSpendPageView
// Limits config
<SpendPageView
configData={configQuery.data}
isLoadingConfig={configQuery.isLoading}
configError={configQuery.isError ? configQuery.error : null}
@@ -160,12 +174,15 @@ const AgentSettingsSpendPage: FC<AgentSettingsSpendPageProps> = ({ 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<AgentSettingsSpendPageProps> = ({ now }) => {
? deleteGroupOverrideMutation.error
: null
}
// Usage data
dateRange={dateRange}
endDateIsExclusive={endDateIsExclusive}
onDateRangeChange={onDateRangeChange}
@@ -226,9 +242,11 @@ const AgentSettingsSpendPage: FC<AgentSettingsSpendPageProps> = ({ now }) => {
isSummaryLoading={summaryQuery.isLoading}
summaryError={summaryQuery.error}
onSummaryRetry={() => void summaryQuery.refetch()}
activeTab={activeTab}
onActiveTabChange={onActiveTabChange}
/>
</RequirePermission>
);
};
export default AgentSettingsSpendPage;
export default SpendPage;
@@ -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 (
<SpendPageView
{...args}
activeTab={activeTab}
onActiveTabChange={(tab) => {
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<typeof AgentSettingsSpendPageView>;
} satisfies Meta<typeof SpendPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsSpendPageView>;
// ── Stories ────────────────────────────────────────────────────
type Story = StoryObj<typeof SpendPageView>;
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);
@@ -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<SpendPageViewProps> = ({
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<Record<string, string | undefined>>((acc, group) => {
acc[group.id] = group.organization_name;
return acc;
}, {}) ?? {};
const { isSavedVisible, showSavedState } = useTemporarySavedState();
if (drillInUserId) {
return (
<SpendDrillInView
selectedUser={drillInUser}
isLoading={isDrillInUserLoading}
isError={isDrillInUserError}
error={drillInUserError}
onRetry={onDrillInUserRetry}
onBack={onClearSelectedUser}
displayDateRange={displayDateRange}
onDateRangeChange={onDateRangeChange}
dateRangeLabel={dateRangeLabel}
summaryData={summaryData}
isSummaryLoading={isSummaryLoading}
summaryError={summaryError}
onSummaryRetry={onSummaryRetry}
/>
);
}
return (
<GroupOverrideController
groupOverrides={groupOverrides}
groups={groupsData ?? []}
isLoadingGroups={isLoadingGroups}
onUpsertGroupOverride={onUpsertGroupOverride}
>
{(groupCtrl) => (
<UserOverrideController
overrides={overrides}
onUpsertOverride={onUpsertOverride}
>
{(userCtrl) => (
<div className="flex max-w-[1100px] flex-col gap-8">
<SettingsHeader>
<SettingsHeaderTitle>
Spend limits and usage
</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure spend limits and monitor AI usage across your
deployment.
</SettingsHeaderDescription>
</SettingsHeader>
<Tabs
value={activeTab}
onValueChange={(tab) => {
if (tab === "limits" || tab === "usage") {
onActiveTabChange(tab);
}
}}
>
<TabsList>
<TabsTrigger value="limits">Spend limits</TabsTrigger>
<TabsTrigger value="usage">Usage</TabsTrigger>
</TabsList>
<TabsContent
value="limits"
className="pt-8 data-[state=inactive]:hidden"
forceMount
>
<div className="space-y-8">
{isLoadingConfig ? (
<div className="flex items-center justify-center rounded-lg border border-border-default px-6 py-10">
<Spinner loading className="h-6 w-6" />
</div>
) : configError ? (
<div className="space-y-4">
<ErrorAlert error={configError} />
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void refetchConfig()}
>
Retry
</Button>
</div>
) : (
<>
<DefaultLimitController
key={defaultLimitKey}
initialValues={defaultLimitValues}
onSave={handleSaveDefault}
>
{({
enabled,
onEnabledChange,
period,
onPeriodChange,
amountDollars,
onAmountDollarsChange,
isAmountValid,
isDirty,
saveDefault,
}) => (
<DefaultLimitSection
enabled={enabled}
onEnabledChange={(v) => {
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 ? (
<TemporarySavedState />
) : updateConfigError ? (
getErrorMessage(
updateConfigError,
"Failed to save the default spend limit.",
)
) : null
}
/>
)}
</DefaultLimitController>
<section className="space-y-6">
<SpendSectionHeader
title="Group limits"
description="Override the default limit for specific groups. The lowest group limit applies."
/>
<GroupLimitsSection
hideHeader
groupOverrides={groupOverrides}
groupOrganizationNames={groupOrganizationNames}
showGroupForm={groupCtrl.showGroupForm}
onShowGroupFormChange={
groupCtrl.handleShowGroupFormChange
}
selectedGroup={groupCtrl.selectedGroup}
onSelectedGroupChange={groupCtrl.setSelectedGroup}
groupAmount={groupCtrl.groupAmount}
onGroupAmountChange={groupCtrl.setGroupAmount}
availableGroups={groupCtrl.availableGroups}
groupAutocompleteNoOptionsText={
groupCtrl.groupAutocompleteNoOptionsText
}
groupsLoading={isLoadingGroups}
editingGroupOverride={
groupCtrl.editingGroupOverride
}
onEditGroupOverride={(override) => {
userCtrl.handleShowUserFormChange(false);
groupCtrl.handleEditGroupOverride(override);
}}
onAddGroupOverride={
groupCtrl.handleAddGroupOverride
}
onDeleteGroupOverride={onDeleteGroupOverride}
upsertPending={isUpsertingGroupOverride}
upsertError={upsertGroupOverrideError}
deletePending={isDeletingGroupOverride}
deleteError={deleteGroupOverrideError}
groupsError={groupsError}
/>
</section>
<section className="space-y-6">
<SpendSectionHeader
title="User overrides"
description="Set user-specific limits. User overrides take highest priority, followed by group limits, then the default."
/>
<UserOverridesSection
hideHeader
overrides={overrides}
showUserForm={userCtrl.showUserForm}
onShowUserFormChange={
userCtrl.handleShowUserFormChange
}
selectedUser={userCtrl.selectedUserOverride}
onSelectedUserChange={
userCtrl.setSelectedUserOverride
}
userOverrideAmount={userCtrl.userOverrideAmount}
onUserOverrideAmountChange={
userCtrl.setUserOverrideAmount
}
selectedUserAlreadyOverridden={
userCtrl.editingUserOverride
? false
: userCtrl.selectedUserAlreadyOverridden
}
editingUserOverride={userCtrl.editingUserOverride}
onEditUserOverride={(override) => {
groupCtrl.handleShowGroupFormChange(false);
userCtrl.handleEditUserOverride(override);
}}
onAddOverride={userCtrl.handleAddOverride}
onDeleteOverride={onDeleteOverride}
upsertPending={isUpsertingOverride}
upsertError={upsertOverrideError}
deletePending={isDeletingOverride}
deleteError={deleteOverrideError}
/>
</section>
</>
)}
</div>
</TabsContent>
<TabsContent value="usage" className="pt-8">
<UsageTab
displayDateRange={displayDateRange}
onDateRangeChange={onDateRangeChange}
searchFilter={searchFilter}
onSearchFilterChange={onSearchFilterChange}
usersQuery={usersQuery}
overrides={overrides}
onSelectUser={onSelectUser}
onEditBudget={(override) => {
groupCtrl.handleShowGroupFormChange(false);
userCtrl.handleEditUserOverride(override);
}}
/>
</TabsContent>
</Tabs>
</div>
)}
</UserOverrideController>
)}
</GroupOverrideController>
);
};
@@ -58,7 +58,7 @@ const emptySummary = buildSummary({
});
const meta: Meta<typeof ChatCostSummaryView> = {
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();
@@ -136,7 +136,7 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({
)} 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")}`
: "";
@@ -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<DefaultLimitControllerProps> = ({
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<DefaultLimitControllerProps> = ({
amountDollars,
onAmountDollarsChange: setAmountDollars,
isAmountValid,
isDirty,
saveDefault: handleSave,
});
};
@@ -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<DefaultLimitSectionProps> = ({
enabled,
onEnabledChange,
period,
onPeriodChange,
amountDollars,
onAmountDollarsChange,
unpricedModelCount,
isSaving = false,
isSavedVisible = false,
saveDisabled = false,
onSave,
saveStatus,
}) => {
return (
<section className="space-y-4">
<div className="flex items-start gap-3">
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
aria-label="Spend limit"
className="mt-0.5"
/>
<div className="flex max-w-[980px] flex-1 flex-col">
<h3 className="m-0 text-sm font-normal leading-6 text-content-primary">
Spend limit
</h3>
<p className="mt-1 mb-0 text-sm font-normal leading-6 text-content-secondary">
Set a deployment-wide spend cap that applies to all users by
default. When disabled, users have unlimited spending.
</p>
<div className="mt-4 flex flex-wrap items-start gap-3">
<InputGroup className="w-32">
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
type="number"
step="0.01"
min="0"
className="text-sm"
value={amountDollars}
onChange={(event) => onAmountDollarsChange(event.target.value)}
placeholder="0.00"
aria-label="Spend limit amount in dollars"
disabled={!enabled}
/>
</InputGroup>
<Select
value={period}
onValueChange={(value) =>
onPeriodChange(value as ChatUsageLimitPeriod)
}
disabled={!enabled}
>
<SelectTrigger
className="h-10 w-32 text-sm"
aria-label="Spend limit period"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">Day</SelectItem>
<SelectItem value="week">Week</SelectItem>
<SelectItem value="month">Month</SelectItem>
</SelectContent>
</Select>
<div className="flex min-h-10 items-center">
{(onSave || isSavedVisible || isSaving) &&
(isSavedVisible ? (
saveStatus
) : (
<Button
size="lg"
type="button"
disabled={saveDisabled}
onClick={onSave}
className="h-10 min-w-[88px]"
>
{isSaving && <Spinner loading className="h-4 w-4" />}
Save
</Button>
))}
</div>
</div>
{saveStatus && !isSavedVisible && (
<div className="text-xs text-content-destructive">{saveStatus}</div>
)}
</div>
</div>
{enabled && unpricedModelCount > 0 && (
<div className="ml-12 flex items-start gap-3 rounded-lg border border-border-warning bg-surface-warning p-4 text-sm text-content-primary">
<TriangleAlertIcon className="size-5 shrink-0 text-content-warning" />
<div>
{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.
</div>
</div>
)}
</section>
);
};
@@ -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<GroupLimitDialogProps> = ({
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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (upsertPending) {
return;
}
onOpenChange(nextOpen);
if (!nextOpen) {
onSelectedGroupChange(null);
onGroupAmountChange("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditing ? "Update group budget" : "Add group budget"}
</DialogTitle>
<DialogDescription>
{isEditing
? "Update this group's spend limit override."
: "Set a spend limit override for a specific group."}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="space-y-1.5">
{editingGroupOverride ? (
<>
<Label>Group</Label>
<div className="rounded-md border border-border bg-surface-secondary/40 p-2">
<AvatarData
title={
editingGroupOverride.group_display_name ||
editingGroupOverride.group_name
}
subtitle={editingGroupOverride.group_name}
src={editingGroupOverride.group_avatar_url}
imgFallbackText={editingGroupOverride.group_name}
/>
</div>
</>
) : (
<>
<Label htmlFor={groupAutocompleteId}>Group</Label>
<Autocomplete
id={groupAutocompleteId}
value={selectedGroup}
onChange={onSelectedGroupChange}
options={availableGroups}
getOptionValue={(group) => group.id}
getOptionLabel={(group) => group.display_name || group.name}
isOptionEqualToValue={(option, optionValue) =>
option.id === optionValue.id
}
renderOption={(option, isSelected) => (
<div className="flex w-full items-center justify-between gap-2">
<AvatarData
title={option.display_name || option.name}
subtitle={getGroupSubtitle(option)}
src={option.avatar_url}
imgFallbackText={option.name}
/>
{isSelected && <CheckIcon className="size-4 shrink-0" />}
</div>
)}
placeholder="Search groups..."
noOptionsText={groupAutocompleteNoOptionsText}
loading={groupsLoading}
disabled={groupsLoading}
className="w-full"
/>
</>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor={groupAmountId}>Spend limit ($)</Label>
<Input
id={groupAmountId}
type="number"
step="0.01"
min="0.01"
disabled={upsertPending}
value={groupAmount}
onChange={(event) => onGroupAmountChange(event.target.value)}
placeholder="0.00"
/>
</div>
{upsertError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(upsertError, "Failed to save group override.")}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => {
onOpenChange(false);
onSelectedGroupChange(null);
onGroupAmountChange("");
}}
disabled={upsertPending}
>
Cancel
</Button>
<Button type="button" onClick={onSave} disabled={saveDisabled}>
{upsertPending ? <Spinner loading className="h-4 w-4" /> : null}
{isEditing ? "Update budget" : "Add group"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -68,14 +68,13 @@ const editingGroupOverride = {
};
const meta: Meta<typeof GroupLimitsSection> = {
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");
@@ -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<string, string | undefined>;
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<GroupLimitsSectionProps> = ({
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 (
<section className="space-y-6">
{!hideHeader && (
<SpendSectionHeader
title="Group limits"
description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies."
/>
)}
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SearchField
value={searchQuery}
onChange={(value) => {
setSearchQuery(value);
setPage(1);
}}
placeholder="Search groups..."
aria-label="Search group limits"
className="w-full sm:max-w-md"
/>
<Button
variant="outline"
size="lg"
type="button"
onClick={() => onShowGroupFormChange(true)}
disabled={
isEditing ||
showGroupForm ||
groupsLoading ||
availableGroups.length === 0
}
>
<PlusIcon />
Add group
</Button>
</div>
<GroupLimitsTable
pagedOverrides={pagedItems}
totalOverrides={filteredGroupOverrides.length}
clampedPage={clampedPage}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
onPageChange={setPage}
onEditGroupOverride={onEditGroupOverride}
onRequestDelete={setPendingDeleteGroupId}
groupOrganizationNames={groupOrganizationNames}
deletePending={deletePending}
upsertPending={upsertPending}
isEditing={isEditing}
emptyMessage={
groupOverrides.length === 0
? "No group overrides configured."
: "No group limits match your search."
}
/>
{deleteError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(deleteError, "Failed to delete group override.")}
</p>
)}
<GroupLimitDialog
open={showGroupForm}
onOpenChange={onShowGroupFormChange}
selectedGroup={selectedGroup}
onSelectedGroupChange={onSelectedGroupChange}
groupAmount={groupAmount}
onGroupAmountChange={onGroupAmountChange}
availableGroups={availableGroups}
groupAutocompleteNoOptionsText={groupAutocompleteNoOptionsText}
groupsLoading={groupsLoading}
editingGroupOverride={editingGroupOverride}
upsertPending={upsertPending}
upsertError={upsertError}
onSave={onAddGroupOverride}
groupAutocompleteId={groupAutocompleteId}
groupAmountId={groupAmountId}
/>
{groupsError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(groupsError, "Failed to load groups.")}
</p>
)}
</div>
{pendingDeleteGroupId && (
<ConfirmDeleteDialog
entity="group override"
onConfirm={() => {
void onDeleteGroupOverride(pendingDeleteGroupId);
setPendingDeleteGroupId(null);
}}
isPending={deletePending}
open
onOpenChange={(open) => !open && setPendingDeleteGroupId(null)}
/>
)}
</section>
);
};
@@ -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<string, string | undefined>;
deletePending: boolean;
upsertPending: boolean;
isEditing: boolean;
emptyMessage: string;
}
export const GroupLimitsTable: FC<GroupLimitsTableProps> = ({
pagedOverrides,
totalOverrides,
clampedPage,
hasPreviousPage,
hasNextPage,
onPageChange,
onEditGroupOverride,
onRequestDelete,
groupOrganizationNames,
deletePending,
upsertPending,
isEditing,
emptyMessage,
}) => {
if (totalOverrides === 0) {
return (
<div className="rounded-lg border border-border bg-surface-secondary px-4 py-6 text-center text-sm text-content-secondary">
{emptyMessage}
</div>
);
}
return (
<>
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group</TableHead>
<TableHead>Members</TableHead>
<TableHead>Spend limit</TableHead>
<TableHead className="w-1">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedOverrides.map((override) => {
const orgName = groupOrganizationNames?.[override.group_id];
const groupAvatar = (
<AvatarData
title={override.group_display_name || override.group_name}
subtitle={override.group_name}
src={override.group_avatar_url}
imgFallbackText={override.group_name}
/>
);
return (
<TableRow key={override.group_id}>
<TableCell>
{orgName ? (
<Link
to={`/organizations/${orgName}/groups/${override.group_name}`}
className="inline-block"
>
{groupAvatar}
</Link>
) : (
groupAvatar
)}
</TableCell>
<TableCell>{override.member_count}</TableCell>
<TableCell>
{override.spend_limit_micros !== null
? formatCostMicros(override.spend_limit_micros)
: "Unlimited"}
</TableCell>
<TableCell className="w-1 whitespace-nowrap text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
type="button"
disabled={deletePending || upsertPending}
aria-label={`Actions for ${
override.group_display_name || override.group_name
}`}
>
<EllipsisVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onEditGroupOverride(override)}
>
Update budget
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isEditing}
onSelect={() => onRequestDelete(override.group_id)}
>
<TrashIcon />
Remove group limit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<PaginationAmount
limit={GROUP_LIMITS_PAGE_SIZE}
totalRecords={totalOverrides}
currentOffsetStart={(clampedPage - 1) * GROUP_LIMITS_PAGE_SIZE + 1}
paginationUnitLabel="groups"
className="justify-end"
/>
</div>
{totalOverrides > GROUP_LIMITS_PAGE_SIZE && (
<PaginationWidgetBase
currentPage={clampedPage}
pageSize={GROUP_LIMITS_PAGE_SIZE}
totalRecords={totalOverrides}
onPageChange={onPageChange}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
/>
)}
</>
);
};
@@ -37,7 +37,6 @@ type GroupOverrideChildProps = {
existingGroupIds: Set<string>;
availableGroups: Group[];
groupAutocompleteNoOptionsText: string;
groupOrganizationNames: Record<string, string>;
};
interface GroupOverrideControllerProps {
@@ -68,10 +67,6 @@ export const GroupOverrideController: FC<GroupOverrideControllerProps> = ({
// Derived values.
const existingGroupIds = new Set(groupOverrides.map((g) => g.group_id));
const availableGroups = groups.filter((g) => !existingGroupIds.has(g.id));
const groupOrganizationNames: Record<string, string> = {};
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<GroupOverrideControllerProps> = ({
existingGroupIds,
availableGroups,
groupAutocompleteNoOptionsText,
groupOrganizationNames,
});
};
@@ -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<UserOverrideDialogProps> = ({
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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (upsertPending) {
return;
}
onOpenChange(nextOpen);
if (!nextOpen) {
onSelectedUserChange(null);
onUserOverrideAmountChange("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditing ? "Update user budget" : "Add user budget"}
</DialogTitle>
<DialogDescription>
{isEditing
? "Update this user's spend limit override."
: "Set a spend limit override for a specific user."}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="space-y-1.5">
{editingUserOverride ? (
<>
<Label>User</Label>
<div className="rounded-md border border-border bg-surface-secondary/40 p-2">
<AvatarData
title={
editingUserOverride.name || editingUserOverride.username
}
subtitle={`@${editingUserOverride.username}`}
src={editingUserOverride.avatar_url}
imgFallbackText={editingUserOverride.username}
/>
</div>
</>
) : (
<UserAutocomplete
value={selectedUser}
onChange={onSelectedUserChange}
label="User"
/>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor={amountInputId}>Spend limit ($)</Label>
<Input
id={amountInputId}
type="number"
step="0.01"
min="0.01"
disabled={upsertPending}
value={userOverrideAmount}
onChange={(event) =>
onUserOverrideAmountChange(event.target.value)
}
placeholder="0.00"
/>
</div>
{!isEditing && selectedUserAlreadyOverridden && (
<p className="text-xs text-content-warning">
This user already has an override.
</p>
)}
{upsertError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(upsertError, "Failed to save the override.")}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => {
onOpenChange(false);
onSelectedUserChange(null);
onUserOverrideAmountChange("");
}}
disabled={upsertPending}
>
Cancel
</Button>
<Button type="button" onClick={onSave} disabled={saveDisabled}>
{upsertPending ? <Spinner loading className="h-4 w-4" /> : null}
{isEditing ? "Update budget" : "Add user"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -38,7 +38,7 @@ const mockOverrides = [
];
const meta: Meta<typeof UserOverridesSection> = {
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");
@@ -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<UserOverridesSectionProps> = ({
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<string | null>(
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 (
<section className="space-y-6">
{!hideHeader && (
<SpendSectionHeader
title="Per-user overrides"
description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default."
/>
)}
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SearchField
value={searchQuery}
onChange={(value) => {
setSearchQuery(value);
setPage(1);
}}
placeholder="Search users..."
aria-label="Search user overrides"
className="w-full sm:max-w-md"
/>
<Button
variant="outline"
size="lg"
type="button"
onClick={() => onShowUserFormChange(true)}
disabled={isEditing || showUserForm}
>
<PlusIcon />
Add user
</Button>
</div>
<UserOverridesTable
pagedOverrides={pagedItems}
totalOverrides={filteredOverrides.length}
clampedPage={clampedPage}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
onPageChange={setPage}
onEditUserOverride={onEditUserOverride}
onRequestDelete={setPendingDeleteUserId}
deletePending={deletePending}
upsertPending={upsertPending}
isEditing={isEditing}
emptyMessage={
overrides.length === 0
? "No overrides configured."
: "No user overrides match your search."
}
/>
{deleteError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(deleteError, "Failed to delete override.")}
</p>
)}
<UserOverrideDialog
open={showUserForm}
onOpenChange={onShowUserFormChange}
selectedUser={selectedUser}
onSelectedUserChange={onSelectedUserChange}
userOverrideAmount={userOverrideAmount}
onUserOverrideAmountChange={onUserOverrideAmountChange}
selectedUserAlreadyOverridden={selectedUserAlreadyOverridden}
editingUserOverride={editingUserOverride}
upsertPending={upsertPending}
upsertError={upsertError}
onSave={onAddOverride}
amountInputId={userOverrideAmountId}
/>
</div>
{pendingDeleteUserId && (
<ConfirmDeleteDialog
entity="user override"
onConfirm={() => {
void onDeleteOverride(pendingDeleteUserId);
setPendingDeleteUserId(null);
}}
isPending={deletePending}
open
onOpenChange={(open) => !open && setPendingDeleteUserId(null)}
/>
)}
</section>
);
};
@@ -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<UserOverridesTableProps> = ({
pagedOverrides,
totalOverrides,
clampedPage,
hasPreviousPage,
hasNextPage,
onPageChange,
onEditUserOverride,
onRequestDelete,
deletePending,
upsertPending,
isEditing,
emptyMessage,
}) => {
if (totalOverrides === 0) {
return (
<div className="rounded-lg border border-border bg-surface-secondary px-4 py-6 text-center text-sm text-content-secondary">
{emptyMessage}
</div>
);
}
return (
<>
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Spend limit</TableHead>
<TableHead className="w-1">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedOverrides.map((override) => (
<TableRow key={override.user_id}>
<TableCell>
<AvatarData
title={override.name || override.username}
subtitle={`@${override.username}`}
src={override.avatar_url}
imgFallbackText={override.username}
/>
</TableCell>
<TableCell>
{override.spend_limit_micros !== null
? formatCostMicros(override.spend_limit_micros)
: "Unlimited"}
</TableCell>
<TableCell className="w-1 whitespace-nowrap text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
type="button"
disabled={deletePending || upsertPending}
aria-label={`Actions for ${override.name || override.username}`}
>
<EllipsisVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onEditUserOverride(override)}
>
Update budget
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isEditing}
onSelect={() => onRequestDelete(override.user_id)}
>
<TrashIcon />
Remove override
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<PaginationAmount
limit={USER_OVERRIDES_PAGE_SIZE}
totalRecords={totalOverrides}
currentOffsetStart={(clampedPage - 1) * USER_OVERRIDES_PAGE_SIZE + 1}
paginationUnitLabel="users"
className="justify-end"
/>
</div>
{totalOverrides > USER_OVERRIDES_PAGE_SIZE && (
<PaginationWidgetBase
currentPage={clampedPage}
pageSize={USER_OVERRIDES_PAGE_SIZE}
totalRecords={totalOverrides}
onPageChange={onPageChange}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
/>
)}
</>
);
};
@@ -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<SpendDrillInViewProps> = ({
const backButton = <BackButton onClick={onBack} />;
const header = (
<SectionHeader
label="Spend management"
<SpendSectionHeader
title="Spend details"
description="Review spend details for a specific user."
action={
actions={
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
@@ -0,0 +1,29 @@
import type { FC, ReactNode } from "react";
interface SpendSectionHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
}
export const SpendSectionHeader: FC<SpendSectionHeaderProps> = ({
title,
description,
actions,
}) => {
return (
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="m-0 text-xl font-semibold leading-7 text-content-primary">
{title}
</h2>
{description && (
<p className="m-0 mt-3 text-sm font-medium leading-6 text-content-secondary">
{description}
</p>
)}
</div>
{actions}
</div>
);
};
@@ -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<UsageTabProps> = ({
displayDateRange,
onDateRangeChange,
searchFilter,
onSearchFilterChange,
usersQuery,
overrides,
onSelectUser,
onEditBudget,
}) => {
return (
<section className="space-y-6">
<SpendSectionHeader
title="Usage by user"
description="Monitor AI usage and spend for users in the selected date range."
actions={
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
}
/>
<div>
<div className="w-full md:max-w-sm">
<SearchField
value={searchFilter}
onChange={onSearchFilterChange}
placeholder="Search by name or username"
aria-label="Search usage by name or username"
/>
</div>
</div>
{usersQuery.isLoading && (
<div
role="status"
aria-label="Loading usage"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersQuery.error != null && (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<ErrorAlert error={usersQuery.error} />
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void usersQuery.refetch()}
>
Retry
</Button>
</div>
)}
{usersQuery.data && (
<div className="relative pt-3">
{usersQuery.isFetching && !usersQuery.isLoading && (
<div
role="status"
aria-label="Refreshing usage"
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-primary/50"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersQuery.data.users.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
No usage data for this period.
</p>
) : (
<PaginationContainer query={usersQuery} paginationUnitLabel="users">
<div className="overflow-hidden rounded-lg border border-border-default">
<Table aria-label="User spend details">
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Messages</TableHead>
<TableHead className="text-right">Chats</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
<TableHead className="text-right">Cache Read</TableHead>
<TableHead className="text-right">Cache Write</TableHead>
<TableHead className="w-1">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.data.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={onSelectUser}
onEditBudget={(selectedUser) => {
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);
}}
/>
))}
</TableBody>
</Table>
</div>
</PaginationContainer>
)}
</div>
)}
</section>
);
};
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 (
<TableRow
{...clickableRowProps}
aria-label={`View details for ${user.name || user.username}`}
className="text-xs"
>
<TableCell className="max-w-[200px] px-3 py-2">
<Tooltip>
<TooltipTrigger asChild>
<div>
<AvatarData
title={
<span className="block truncate">
{user.name || user.username}
</span>
}
subtitle={
<span className="block truncate">@{user.username}</span>
}
src={user.avatar_url}
imgFallbackText={user.username}
/>
</div>
</TooltipTrigger>
<TooltipContent>{user.name || user.username}</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCostMicros(user.total_cost_micros)}
</TableCell>
<TableCell className="text-right tabular-nums">
{user.message_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{user.chat_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_input_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_output_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_cache_read_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_cache_creation_tokens)}
</TableCell>
<TableCell className="w-1" onClick={(event) => event.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="subtle"
aria-label={`Open spend actions for ${user.name || user.username}`}
>
<EllipsisVerticalIcon aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEditBudget(user)}>
Update budget
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSelect(user)}>
View spend details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
};
@@ -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");
});
});
@@ -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")}`;
}
@@ -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 {
@@ -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 (
<TableRow
{...clickableRowProps}
aria-label={`View details for ${user.name || user.username}`}
className="text-xs"
>
<TableCell className="max-w-[200px] px-3 py-2">
<Tooltip>
<TooltipTrigger asChild>
<div>
<AvatarData
title={
<span className="block truncate">
{user.name || user.username}
</span>
}
subtitle={
<span className="block truncate">@{user.username}</span>
}
src={user.avatar_url}
imgFallbackText={user.username}
/>
</div>
</TooltipTrigger>
<TooltipContent>{user.name || user.username}</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCostMicros(user.total_cost_micros)}
</TableCell>
<TableCell className="text-right tabular-nums">
{user.message_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{user.chat_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_input_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_output_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_cache_read_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_cache_creation_tokens)}
</TableCell>
</TableRow>
);
};
// ── Props ──
interface AgentSettingsSpendPageViewProps {
// Limits config.
configData: TypesGen.ChatUsageLimitConfigResponse | undefined;
isLoadingConfig: boolean;
configError: Error | null;
refetchConfig: () => void;
groupsData: TypesGen.Group[] | undefined;
isLoadingGroups: boolean;
groupsError: Error | null;
onUpdateConfig: (req: TypesGen.ChatUsageLimitConfig) => void;
isUpdatingConfig: boolean;
updateConfigError: Error | null;
isUpdateConfigSuccess: boolean;
resetUpdateConfig: () => void;
onUpsertOverride: (args: {
userID: string;
req: TypesGen.UpsertChatUsageLimitOverrideRequest;
onSuccess: () => void;
}) => void;
isUpsertingOverride: boolean;
upsertOverrideError: Error | null;
onDeleteOverride: (userID: string) => void;
isDeletingOverride: boolean;
deleteOverrideError: Error | null;
onUpsertGroupOverride: (args: {
groupID: string;
req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest;
onSuccess: () => void;
}) => void;
isUpsertingGroupOverride: boolean;
upsertGroupOverrideError: Error | null;
onDeleteGroupOverride: (groupID: string) => void;
isDeletingGroupOverride: boolean;
deleteGroupOverrideError: Error | null;
// Usage data.
dateRange: DateRangeValue;
endDateIsExclusive: boolean;
onDateRangeChange: (value: DateRangeValue) => void;
searchFilter: string;
onSearchFilterChange: (value: string) => void;
usersQuery: PaginationResult & {
data: TypesGen.ChatCostUsersResponse | undefined;
isLoading: boolean;
isFetching: boolean;
error: unknown;
refetch: () => unknown;
};
drillInUserId: string | null;
drillInUser: TypesGen.User | null;
isDrillInUserLoading: boolean;
isDrillInUserError: boolean;
drillInUserError: unknown;
onDrillInUserRetry: () => void;
onClearSelectedUser: () => void;
onSelectUser: (user: TypesGen.ChatCostUserRollup) => void;
summaryData: TypesGen.ChatCostSummary | undefined;
isSummaryLoading: boolean;
summaryError: unknown;
onSummaryRetry: () => void;
}
// ── View component ──
export const AgentSettingsSpendPageView: FC<
AgentSettingsSpendPageViewProps
> = ({
configData,
isLoadingConfig,
configError,
refetchConfig,
groupsData,
isLoadingGroups,
groupsError,
onUpdateConfig,
isUpdatingConfig,
updateConfigError,
isUpdateConfigSuccess,
resetUpdateConfig,
onUpsertOverride,
isUpsertingOverride,
upsertOverrideError,
onDeleteOverride,
isDeletingOverride,
deleteOverrideError,
onUpsertGroupOverride,
isUpsertingGroupOverride,
upsertGroupOverrideError,
onDeleteGroupOverride,
isDeletingGroupOverride,
deleteGroupOverrideError,
dateRange,
endDateIsExclusive,
onDateRangeChange,
searchFilter,
onSearchFilterChange,
usersQuery,
drillInUserId,
drillInUser,
isDrillInUserLoading,
isDrillInUserError,
drillInUserError,
onDrillInUserRetry,
onClearSelectedUser,
onSelectUser,
summaryData,
isSummaryLoading,
summaryError,
onSummaryRetry,
}) => {
// ── Derived limit values ──
const defaultLimitValues: DefaultLimitFormValues = (() => {
const spendLimitMicros = configData?.spend_limit_micros;
const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined;
return {
enabled,
period: normalizeChatUsageLimitPeriod(configData?.period),
amountDollars: enabled
? microsToDollars(spendLimitMicros).toString()
: "",
};
})();
const defaultLimitKey = JSON.stringify({
spend_limit_micros: configData?.spend_limit_micros ?? null,
period: defaultLimitValues.period,
});
// ── Derived usage display state ──
const displayDateRange = toInclusiveDateRange(dateRange, endDateIsExclusive);
const dateRangeLabel = formatUsageDateRange(dateRange, {
endDateIsExclusive,
});
// ── Limits handlers ──
const handleResetUpdateConfig = () => {
if (!isUpdatingConfig) {
resetUpdateConfig();
}
};
const handleSaveDefault = ({
enabled,
period,
amountDollars,
}: DefaultLimitFormValues) => {
const spendLimitMicros = enabled ? dollarsToMicros(amountDollars) : null;
onUpdateConfig({
spend_limit_micros: spendLimitMicros,
period,
updated_at: new Date().toISOString(),
});
};
const groupOverrides = configData?.group_overrides ?? [];
const overrides = configData?.overrides ?? [];
const unpricedModelCount = configData?.unpriced_model_count ?? 0;
if (drillInUserId) {
return (
<SpendDrillInView
selectedUser={drillInUser}
isLoading={isDrillInUserLoading}
isError={isDrillInUserError}
error={drillInUserError}
onRetry={onDrillInUserRetry}
onBack={onClearSelectedUser}
displayDateRange={displayDateRange}
onDateRangeChange={onDateRangeChange}
dateRangeLabel={dateRangeLabel}
summaryData={summaryData}
isSummaryLoading={isSummaryLoading}
summaryError={summaryError}
onSummaryRetry={onSummaryRetry}
/>
);
}
// ── List mode ──
return (
<GroupOverrideController
groupOverrides={groupOverrides}
groups={groupsData ?? []}
isLoadingGroups={isLoadingGroups}
onUpsertGroupOverride={onUpsertGroupOverride}
>
{(groupCtrl) => (
<UserOverrideController
overrides={overrides}
onUpsertOverride={onUpsertOverride}
>
{(userCtrl) => (
<div className="space-y-10">
<SectionHeader
label="Spend management"
description="Configure spend limits and monitor usage across your deployment."
/>
{isLoadingConfig ? (
<div className="flex items-center justify-center rounded-lg border border-border-default px-6 py-10">
<Spinner loading className="h-6 w-6" />
</div>
) : configError ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border-default px-6 py-10 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(
configError,
"Failed to load spend limit settings.",
)}
</p>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void refetchConfig()}
>
Retry
</Button>
</div>
) : (
<>
{/* Section 1: Default spend limit */}
<DefaultLimitController
key={defaultLimitKey}
initialValues={defaultLimitValues}
onSave={handleSaveDefault}
>
{({
enabled,
onEnabledChange,
period,
onPeriodChange,
amountDollars,
onAmountDollarsChange,
isAmountValid,
saveDefault,
}) => (
<section>
<SectionHeader
level="section"
label="Default spend limit"
description="Set a deployment-wide spend cap that applies to all users by default."
/>
<DefaultLimitSection
hideHeader
adminBadge={null}
enabled={enabled}
onEnabledChange={(v) => {
handleResetUpdateConfig();
onEnabledChange(v);
}}
period={period}
onPeriodChange={(v) => {
handleResetUpdateConfig();
onPeriodChange(v);
}}
amountDollars={amountDollars}
onAmountDollarsChange={(v) => {
handleResetUpdateConfig();
onAmountDollarsChange(v);
}}
unpricedModelCount={unpricedModelCount}
/>
<div className="flex items-center justify-end gap-3 pt-4">
<div className="min-h-4 text-xs">
{updateConfigError && (
<p className="m-0 text-content-destructive">
{getErrorMessage(
updateConfigError,
"Failed to save the default spend limit.",
)}
</p>
)}
{isUpdateConfigSuccess && (
<p className="m-0 text-content-success">Saved!</p>
)}
</div>
<Button
size="sm"
type="button"
onClick={saveDefault}
disabled={isUpdatingConfig || !isAmountValid}
>
{isUpdatingConfig ? (
<Spinner loading className="h-4 w-4" />
) : null}
Save default limit
</Button>
</div>
</section>
)}
</DefaultLimitController>
{/* Section 2: Group limits */}
<section>
<SectionHeader
level="section"
label="Group limits"
description="Override the default limit for specific groups. The lowest group limit applies."
/>
<GroupLimitsSection
hideHeader
groupOverrides={groupOverrides}
groupOrganizationNames={groupCtrl.groupOrganizationNames}
showGroupForm={groupCtrl.showGroupForm}
onShowGroupFormChange={
groupCtrl.handleShowGroupFormChange
}
selectedGroup={groupCtrl.selectedGroup}
onSelectedGroupChange={groupCtrl.setSelectedGroup}
groupAmount={groupCtrl.groupAmount}
onGroupAmountChange={groupCtrl.setGroupAmount}
availableGroups={groupCtrl.availableGroups}
groupAutocompleteNoOptionsText={
groupCtrl.groupAutocompleteNoOptionsText
}
groupsLoading={isLoadingGroups}
editingGroupOverride={groupCtrl.editingGroupOverride}
onEditGroupOverride={(override) => {
userCtrl.handleShowUserFormChange(false);
groupCtrl.handleEditGroupOverride(override);
}}
onAddGroupOverride={groupCtrl.handleAddGroupOverride}
onDeleteGroupOverride={onDeleteGroupOverride}
upsertPending={isUpsertingGroupOverride}
upsertError={upsertGroupOverrideError}
deletePending={isDeletingGroupOverride}
deleteError={deleteGroupOverrideError}
groupsError={groupsError}
/>
</section>
</>
)}
{/* Section 3: Per-user spend */}
<section>
<SectionHeader
level="section"
label="Per-user spend"
description="User overrides take highest priority, followed by group limits, then the default."
/>
<div className="flex items-center justify-between pb-4">
<span className="text-sm font-medium text-content-primary">
Date range
</span>
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
</div>
{!configError && !isLoadingConfig && (
<UserOverridesSection
hideHeader
overrides={overrides}
showUserForm={userCtrl.showUserForm}
onShowUserFormChange={userCtrl.handleShowUserFormChange}
selectedUser={userCtrl.selectedUserOverride}
onSelectedUserChange={userCtrl.setSelectedUserOverride}
userOverrideAmount={userCtrl.userOverrideAmount}
onUserOverrideAmountChange={userCtrl.setUserOverrideAmount}
selectedUserAlreadyOverridden={
userCtrl.editingUserOverride
? false
: userCtrl.selectedUserAlreadyOverridden
}
editingUserOverride={userCtrl.editingUserOverride}
onEditUserOverride={(override) => {
groupCtrl.handleShowGroupFormChange(false);
userCtrl.handleEditUserOverride(override);
}}
onAddOverride={userCtrl.handleAddOverride}
onDeleteOverride={onDeleteOverride}
upsertPending={isUpsertingOverride}
upsertError={upsertOverrideError}
deletePending={isDeletingOverride}
deleteError={deleteOverrideError}
/>
)}
{/* Search */}
<div className="pt-6">
<div className="w-full md:max-w-sm">
<SearchField
value={searchFilter}
onChange={onSearchFilterChange}
placeholder="Search by name or username"
aria-label="Search usage by name or username"
/>
</div>
</div>
{/* Loading state */}
{usersQuery.isLoading && (
<div
role="status"
aria-label="Loading usage"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner
size="lg"
loading
className="text-content-secondary"
/>
</div>
)}
{/* Error state */}
{usersQuery.error != null && (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(
usersQuery.error,
"Failed to load usage data.",
)}
</p>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void usersQuery.refetch()}
>
Retry
</Button>
</div>
)}
{/* User table + pagination */}
{usersQuery.data && (
<div className="relative pt-3">
{usersQuery.isFetching && !usersQuery.isLoading && (
<div
role="status"
aria-label="Refreshing usage"
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-primary/50"
>
<Spinner
size="lg"
loading
className="text-content-secondary"
/>
</div>
)}
{usersQuery.data.users.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
No usage data for this period.
</p>
) : (
<PaginationContainer
query={usersQuery}
paginationUnitLabel="users"
>
<div className="overflow-hidden rounded-lg border border-border-default">
<Table aria-label="Per-user spend">
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead className="text-right">
Cost
</TableHead>
<TableHead className="text-right">
Messages
</TableHead>
<TableHead className="text-right">
Chats
</TableHead>
<TableHead className="text-right">
Input
</TableHead>
<TableHead className="text-right">
Output
</TableHead>
<TableHead className="text-right">
Cache Read
</TableHead>
<TableHead className="text-right">
Cache Write
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.data.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={onSelectUser}
/>
))}
</TableBody>
</Table>
</div>
</PaginationContainer>
)}
</div>
)}
</section>
</div>
)}
</UserOverrideController>
)}
</GroupOverrideController>
);
};
@@ -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: <AgentsRouteElement /> },
{ path: "agents", element: <AgentsRouteElement /> },
{ path: "spend", element: <AgentSettingsSpendPage now={fixedNow} /> },
{
path: "spend",
element: <Navigate to="/ai/settings/spend" replace />,
},
{
path: "usage",
element: <Navigate to="/agents/settings/spend" replace />,
element: <Navigate to="/ai/settings/spend" replace />,
},
],
},
@@ -228,6 +230,11 @@ const agentsRouting = {
],
};
const aiSettingsRouting = {
path: "/ai/settings/spend",
element: <div>Spend limits and usage</div>,
};
const setInnerWidthForStory = (width: number) => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "innerWidth");
Object.defineProperty(globalThis, "innerWidth", {
@@ -354,7 +361,7 @@ const meta: Meta<typeof AgentsPageView> = {
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();
});
},
};
@@ -194,9 +194,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
<SettingsNavItem
icon={CoinsIcon}
label="Spend"
active={settingsSection === "spend"}
to="/agents/settings/spend"
state={location.state}
active={false}
to="/ai/settings/spend"
trailingIcon={ArrowUpRightIcon}
/>
<SettingsNavItem
icon={ReceiptTextIcon}
@@ -1,141 +0,0 @@
import { InfoIcon, TriangleAlertIcon } from "lucide-react";
import { type FC, type ReactNode, useId } from "react";
import type { ChatUsageLimitPeriod } from "#/api/typesGenerated";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { SectionHeader } from "../SectionHeader";
interface DefaultLimitSectionProps {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
period: ChatUsageLimitPeriod;
onPeriodChange: (period: ChatUsageLimitPeriod) => void;
amountDollars: string;
onAmountDollarsChange: (amount: string) => void;
unpricedModelCount: number;
adminBadge: ReactNode;
hideHeader?: boolean;
}
export const DefaultLimitSection: FC<DefaultLimitSectionProps> = ({
enabled,
onEnabledChange,
period,
onPeriodChange,
amountDollars,
onAmountDollarsChange,
unpricedModelCount,
adminBadge,
hideHeader,
}) => {
const periodId = useId();
const amountId = useId();
return (
<section className="space-y-4">
{!hideHeader && (
<SectionHeader
label="Default spend limit"
description="Set a deployment-wide spend cap that applies to all users by default."
badge={adminBadge}
/>
)}
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="m-0 text-sm font-medium text-content-primary">
Enable spend limit
</p>
<p className="m-0 text-xs text-content-secondary">
When disabled, users have unlimited spending.
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
aria-label="Enable spend limit"
/>
</div>
{enabled && (
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-1">
<Label htmlFor={periodId}>Period</Label>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="size-3.5 shrink-0 cursor-help text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
Only one period can be active at a time. Spend is
calculated from the start of the current period.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select
value={period}
onValueChange={(value) =>
onPeriodChange(value as ChatUsageLimitPeriod)
}
>
<SelectTrigger
id={periodId}
className="h-9 min-w-0 text-[13px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">Day</SelectItem>
<SelectItem value="week">Week</SelectItem>
<SelectItem value="month">Month</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1">
<Label htmlFor={amountId}>Amount ($)</Label>
<Input
id={amountId}
type="number"
step="0.01"
min="0"
className="h-9 min-w-0 text-[13px]"
value={amountDollars}
onChange={(event) => onAmountDollarsChange(event.target.value)}
placeholder="0.00"
/>
</div>
</div>
)}
</div>
{enabled && unpricedModelCount > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-border-warning bg-surface-warning p-4 text-sm text-content-primary">
<TriangleAlertIcon className="size-5 shrink-0 text-content-warning" />
<div>
{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.
</div>
</div>
)}
</section>
);
};
@@ -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<string, string>;
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<GroupLimitsSectionProps> = ({
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 (
<section className="space-y-4">
{!hideHeader && (
<SectionHeader
label="Group limits"
description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies."
/>
)}
<div className="space-y-4">
{groupOverrides.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Group</TableHead>
<TableHead>Members</TableHead>
<TableHead>Spend limit</TableHead>
<TableHead className="w-[160px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupOverrides.map((override) => {
const orgName = groupOrganizationNames?.[override.group_id];
const groupLink = orgName
? `/organizations/${orgName}/groups/${override.group_name}`
: undefined;
const avatar = (
<AvatarData
title={override.group_display_name || override.group_name}
subtitle={override.group_name}
src={override.group_avatar_url}
imgFallbackText={override.group_name}
/>
);
return (
<TableRow key={override.group_id}>
<TableCell>
{groupLink ? (
<Link
to={groupLink}
className="flex rounded-md no-underline text-inherit -m-1.5 p-1.5 transition-colors hover:bg-surface-secondary"
>
{avatar}
</Link>
) : (
avatar
)}
</TableCell>
<TableCell>{override.member_count}</TableCell>
<TableCell>
{override.spend_limit_micros !== null
? formatCostMicros(override.spend_limit_micros)
: "Unlimited"}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onEditGroupOverride(override)}
disabled={deletePending || upsertPending}
>
Edit
</Button>
<Button
variant="outline"
size="sm"
type="button"
onClick={() =>
setPendingDeleteGroupId(override.group_id)
}
disabled={deletePending || upsertPending || isEditing}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (
<div className="rounded-lg border border-border bg-surface-secondary px-4 py-6 text-center text-sm text-content-secondary">
No group overrides configured.
</div>
)}
{deleteError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(deleteError, "Failed to delete group override.")}
</p>
)}
{!showGroupForm ? (
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onShowGroupFormChange(true)}
disabled={
isEditing || groupsLoading || availableGroups.length === 0
}
>
Add Group
</Button>
) : (
<div className="space-y-3 rounded-lg border border-border bg-surface-secondary/40 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1 space-y-1">
{editingGroupOverride ? (
<>
<Label>Group</Label>
<div className="rounded-md border border-border bg-surface-primary p-2">
<AvatarData
title={
editingGroupOverride.group_display_name ||
editingGroupOverride.group_name
}
subtitle={editingGroupOverride.group_name}
src={editingGroupOverride.group_avatar_url}
imgFallbackText={editingGroupOverride.group_name}
/>
</div>
</>
) : (
<>
<Label htmlFor={groupAutocompleteId}>Group</Label>
<Autocomplete
id={groupAutocompleteId}
value={selectedGroup}
onChange={onSelectedGroupChange}
options={availableGroups}
getOptionValue={(group) => group.id}
getOptionLabel={(group) =>
group.display_name || group.name
}
isOptionEqualToValue={(option, optionValue) =>
option.id === optionValue.id
}
renderOption={(option, isSelected) => (
<div className="flex w-full items-center justify-between gap-2">
<AvatarData
title={option.display_name || option.name}
subtitle={getGroupSubtitle(option)}
src={option.avatar_url}
imgFallbackText={option.name}
/>
{isSelected && (
<CheckIcon className="size-4 shrink-0" />
)}
</div>
)}
placeholder="Search groups..."
noOptionsText={groupAutocompleteNoOptionsText}
loading={groupsLoading}
disabled={groupsLoading}
className="w-full"
/>
</>
)}
</div>
<div className="flex-1 space-y-1">
<Label htmlFor={groupAmountId}>Spend limit ($)</Label>
<Input
id={groupAmountId}
type="number"
step="0.01"
min="0.01"
disabled={upsertPending}
className="h-9 min-w-0 text-[13px]"
value={groupAmount}
onChange={(event) => onGroupAmountChange(event.target.value)}
placeholder="0.00"
/>
</div>
<div className="flex gap-2 md:pb-0.5">
<Button
size="sm"
type="button"
onClick={() => void onAddGroupOverride()}
disabled={
isEditing
? upsertPending ||
!isPositiveFiniteDollarAmount(groupAmount)
: upsertPending ||
selectedGroup === null ||
!isPositiveFiniteDollarAmount(groupAmount)
}
>
{upsertPending ? (
<Spinner loading className="h-4 w-4" />
) : null}
{isEditing ? "Save" : "Add"}
</Button>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => {
onShowGroupFormChange(false);
onSelectedGroupChange(null);
onGroupAmountChange("");
}}
disabled={upsertPending}
>
Cancel
</Button>
</div>
</div>
</div>
)}
{upsertError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(upsertError, "Failed to save group override.")}
</p>
)}
{groupsError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(groupsError, "Failed to load groups.")}
</p>
)}
</div>
{pendingDeleteGroupId && (
<ConfirmDeleteDialog
entity="group override"
onConfirm={() => {
void onDeleteGroupOverride(pendingDeleteGroupId);
setPendingDeleteGroupId(null);
}}
isPending={deletePending}
open
onOpenChange={(open) => !open && setPendingDeleteGroupId(null)}
/>
)}{" "}
</section>
);
};
@@ -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<UserOverridesSectionProps> = ({
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<string | null>(
null,
);
return (
<section className="space-y-4">
{!hideHeader && (
<SectionHeader
label="Per-user overrides"
description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default."
/>
)}
<div className="space-y-4">
{overrides.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Spend limit</TableHead>
<TableHead className="w-[160px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overrides.map((override) => (
<TableRow key={override.user_id}>
<TableCell>
<AvatarData
title={override.name || override.username}
subtitle={`@${override.username}`}
src={override.avatar_url}
imgFallbackText={override.username}
/>
</TableCell>
<TableCell>
{override.spend_limit_micros !== null
? formatCostMicros(override.spend_limit_micros)
: "Unlimited"}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onEditUserOverride(override)}
disabled={deletePending || upsertPending}
>
Edit
</Button>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => setPendingDeleteUserId(override.user_id)}
disabled={deletePending || upsertPending || isEditing}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="rounded-lg border border-border bg-surface-secondary px-4 py-6 text-center text-sm text-content-secondary">
No overrides configured.
</div>
)}
{deleteError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(deleteError, "Failed to delete override.")}
</p>
)}
{!showUserForm ? (
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onShowUserFormChange(true)}
disabled={isEditing}
>
Add User
</Button>
) : (
<div className="space-y-3 rounded-lg border border-border bg-surface-secondary/40 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1 space-y-1">
{editingUserOverride ? (
<>
<Label>User</Label>
<div className="rounded-md border border-border bg-surface-primary p-2">
<AvatarData
title={
editingUserOverride.name ||
editingUserOverride.username
}
subtitle={`@${editingUserOverride.username}`}
src={editingUserOverride.avatar_url}
imgFallbackText={editingUserOverride.username}
/>
</div>
</>
) : (
<UserAutocomplete
value={selectedUser}
onChange={onSelectedUserChange}
label="User"
/>
)}
</div>
<div className="flex-1 space-y-1">
<Label htmlFor={userOverrideAmountId}>Spend limit ($)</Label>
<Input
id={userOverrideAmountId}
type="number"
step="0.01"
min="0.01"
disabled={upsertPending}
className="h-9 min-w-0 text-[13px]"
value={userOverrideAmount}
onChange={(event) =>
onUserOverrideAmountChange(event.target.value)
}
placeholder="0.00"
/>
</div>
<div className="flex gap-2 md:pb-0.5">
<Button
size="sm"
type="button"
onClick={() => void onAddOverride()}
disabled={
isEditing
? upsertPending ||
!isPositiveFiniteDollarAmount(userOverrideAmount)
: upsertPending ||
!selectedUser ||
selectedUserAlreadyOverridden ||
!isPositiveFiniteDollarAmount(userOverrideAmount)
}
>
{upsertPending ? (
<Spinner loading className="h-4 w-4" />
) : null}
{isEditing ? "Save" : "Add"}
</Button>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => {
onShowUserFormChange(false);
onSelectedUserChange(null);
onUserOverrideAmountChange("");
}}
disabled={upsertPending}
>
Cancel
</Button>
</div>
</div>
</div>
)}
{!isEditing && selectedUserAlreadyOverridden && (
<p className="text-xs text-content-warning">
This user already has an override.
</p>
)}
{upsertError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(upsertError, "Failed to save the override.")}
</p>
)}
</div>
{pendingDeleteUserId && (
<ConfirmDeleteDialog
entity="user override"
onConfirm={() => {
void onDeleteOverride(pendingDeleteUserId);
setPendingDeleteUserId(null);
}}
isPending={deletePending}
open
onOpenChange={(open) => !open && setPendingDeleteUserId(null)}
/>
)}{" "}
</section>
);
};
@@ -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";
}
@@ -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 = {
+15 -5
View File
@@ -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(
/>
<Route index element={<AISettingsIndexRedirect />} />
<Route path="models" element={<AISettingsModelsPage />} />
<Route path="spend" element={<AISettingsSpendPage />} />
<Route
path="instructions"
element={<AISettingsInstructionsPage />}
@@ -893,9 +894,18 @@ export const router = createBrowserRouter(
path="mcp-servers"
element={<Navigate to="/ai/settings/mcp-servers" replace />}
/>
<Route path="spend" element={<AgentSettingsSpendPage />} />
<Route path="limits" element={<Navigate to="spend" replace />} />
<Route path="usage" element={<NavigateWithSearch to="spend" />} />
<Route
path="spend"
element={<NavigateWithSearch to="/ai/settings/spend" />}
/>
<Route
path="limits"
element={<NavigateWithSearch to="/ai/settings/spend" />}
/>
<Route
path="usage"
element={<NavigateWithSearch to="/ai/settings/spend" />}
/>
<Route
path="templates"
element={<Navigate to="/ai/settings/templates" replace />}