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