mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): move spend page to AI settings (#26800)
This commit is contained in:
@@ -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
|
||||
|
||||
+47
-29
@@ -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;
|
||||
+43
-39
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -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();
|
||||
|
||||
+1
-1
@@ -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")}`
|
||||
: "";
|
||||
+6
@@ -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>
|
||||
);
|
||||
};
|
||||
+19
-16
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-6
@@ -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>
|
||||
);
|
||||
};
|
||||
+17
-13
@@ -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");
|
||||
+170
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+4
-4
@@ -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>
|
||||
);
|
||||
};
|
||||
+8
-8
@@ -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");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user