feat(site): add chat cost analytics frontend (#23037)

Add UI components for viewing and managing LLM chat cost analytics.

## Changes
- `UserAnalyticsDialog`: personal cost summary with 30-day date range
- `ChatCostSummaryView`: shared component for cost breakdowns by model
and chat
- `ConfigureAgentsDialog`: admin Usage tab with deployment-wide cost
rollup
- Storybook stories for all new and existing components
- Replace `ModelsSection.test.tsx`, `DashboardLayout.test.tsx`,
`AuditPage.test.tsx` with Storybook stories
- Cost-related API client methods and React Query hooks
- Analytics utilities for formatting microdollar values

Backend: #23036
This commit is contained in:
Michael Suchacz
2026-03-13 18:59:14 +00:00
committed by GitHub
parent 84527390c6
commit eacabd8390
19 changed files with 1540 additions and 42 deletions
+15
View File
@@ -209,6 +209,21 @@ seems like it should use `time.Sleep`, read through https://github.com/coder/qua
- Follow [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
- Commit format: `type(scope): message`
### Frontend Patterns
- Prefer existing shared UI components and utilities over custom
implementations. Reuse common primitives such as loading, table, and error
handling components when they fit the use case.
- Use Storybook stories for all component and page testing, including
visual presentation, user interactions, keyboard navigation, focus
management, and accessibility behavior. Do not create standalone
vitest/RTL test files for components or pages. Stories double as living
documentation, visual regression coverage, and interaction test suites
via `play` functions. Reserve plain vitest files for pure logic only:
utility functions, data transformations, hooks tested via
`renderHook()` that do not require DOM assertions, and query/cache
operations with no rendered output.
### Writing Comments
Code comments should be clear, well-formatted, and add meaningful context.
+35 -1
View File
@@ -186,7 +186,7 @@ export function watchInboxNotifications(
export const getURLWithSearchParams = (
basePath: string,
options?: SearchParamOptions,
options?: object,
): string => {
if (!options) {
return basePath;
@@ -408,6 +408,17 @@ export type DeploymentConfig = Readonly<{
const chatProviderConfigsPath = "/api/experimental/chats/providers";
const chatModelConfigsPath = "/api/experimental/chats/model-configs";
type ChatCostDateParams = {
start_date?: string;
end_date?: string;
};
type ChatCostUsersParams = ChatCostDateParams & {
username?: string;
limit?: number;
offset?: number;
};
type Claims = {
license_expires: number;
// nbf is a standard JWT claim for "not before" - the license valid from date
@@ -3172,6 +3183,29 @@ class ApiMethods {
const response = await this.axios.get<string[]>(url);
return response.data;
};
getChatCostSummary = async (
user = "me",
params?: ChatCostDateParams,
): Promise<TypesGen.ChatCostSummary> => {
const url = getURLWithSearchParams(
`/api/experimental/chats/cost/${encodeURIComponent(user)}/summary`,
params,
);
const response = await this.axios.get<TypesGen.ChatCostSummary>(url);
return response.data;
};
getChatCostUsers = async (
params?: ChatCostUsersParams,
): Promise<TypesGen.ChatCostUsersResponse> => {
const url = getURLWithSearchParams(
"/api/experimental/chats/cost/users",
params,
);
const response = await this.axios.get<TypesGen.ChatCostUsersResponse>(url);
return response.data;
};
}
export type TaskFeedbackRating = "good" | "okay" | "bad";
+43 -1
View File
@@ -48,12 +48,54 @@ export interface ModelOptionsSchema {
export const modelOptionsSchema: ModelOptionsSchema =
schema as ModelOptionsSchema;
const syntheticGeneralFields: FieldSchema[] = [
{
json_name: "cost.input_price_per_million_tokens",
go_name: "Cost.InputPricePerMillionTokens",
type: "number",
description: "Input token price in USD per 1M tokens",
required: false,
input_type: "input",
},
{
json_name: "cost.output_price_per_million_tokens",
go_name: "Cost.OutputPricePerMillionTokens",
type: "number",
description: "Output token price in USD per 1M tokens",
required: false,
input_type: "input",
},
{
json_name: "cost.cache_read_price_per_million_tokens",
go_name: "Cost.CacheReadPricePerMillionTokens",
type: "number",
description: "Cache read token price in USD per 1M tokens",
required: false,
input_type: "input",
},
{
json_name: "cost.cache_write_price_per_million_tokens",
go_name: "Cost.CacheWritePricePerMillionTokens",
type: "number",
description:
"Cache write or cache creation token price in USD per 1M tokens",
required: false,
input_type: "input",
},
];
/**
* Get the general (provider-independent) fields such as temperature
* and max_output_tokens.
*/
export function getGeneralFields(): FieldSchema[] {
return modelOptionsSchema.general.fields;
const fields = [...modelOptionsSchema.general.fields];
for (const field of syntheticGeneralFields) {
if (!fields.some((existing) => existing.json_name === field.json_name)) {
fields.push(field);
}
}
return fields;
}
/**
+58 -1
View File
@@ -2,12 +2,23 @@ import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { QueryClient } from "react-query";
import { describe, expect, it, vi } from "vitest";
import { archiveChat, chatKey, chatsKey, unarchiveChat } from "./chats";
import {
archiveChat,
chatCostSummary,
chatCostSummaryKey,
chatCostUsers,
chatCostUsersKey,
chatKey,
chatsKey,
unarchiveChat,
} from "./chats";
vi.mock("api/api", () => ({
API: {
archiveChat: vi.fn(),
unarchiveChat: vi.fn(),
getChatCostSummary: vi.fn(),
getChatCostUsers: vi.fn(),
},
}));
@@ -271,3 +282,49 @@ describe("unarchiveChat optimistic update", () => {
});
});
});
describe("chat cost query factories", () => {
it("builds the summary query key and forwards snake_case params", async () => {
const user = "user-1";
const params = {
start_date: "2025-01-01",
end_date: "2025-01-31",
};
vi.mocked(API.getChatCostSummary).mockResolvedValue(
{} as TypesGen.ChatCostSummary,
);
const query = chatCostSummary(user, params);
expect(chatCostSummaryKey(user, params)).toEqual([
"chats",
"costSummary",
user,
params,
]);
expect(query.queryKey).toEqual(["chats", "costSummary", user, params]);
await query.queryFn();
expect(API.getChatCostSummary).toHaveBeenCalledWith(user, params);
});
it("builds a distinct users query key and forwards snake_case params", async () => {
const params = {
start_date: "2025-01-01",
end_date: "2025-01-31",
username: "alice",
limit: 10,
offset: 20,
};
vi.mocked(API.getChatCostUsers).mockResolvedValue(
{} as TypesGen.ChatCostUsersResponse,
);
const query = chatCostUsers(params);
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.getChatCostUsers).toHaveBeenCalledWith(params);
});
});
+29
View File
@@ -396,3 +396,32 @@ export const deleteChatModelConfig = (queryClient: QueryClient) => ({
await invalidateChatConfigurationQueries(queryClient);
},
});
type ChatCostDateParams = {
start_date?: string;
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;
export const chatCostSummary = (user = "me", params?: ChatCostDateParams) => ({
queryKey: chatCostSummaryKey(user, params),
queryFn: () => API.getChatCostSummary(user, params),
staleTime: 60_000,
});
export const chatCostUsersKey = (params?: ChatCostUsersParams) =>
[...chatsKey, "costUsers", params] as const;
export const chatCostUsers = (params?: ChatCostUsersParams) => ({
queryKey: chatCostUsersKey(params),
queryFn: () => API.getChatCostUsers(params),
staleTime: 60_000,
});
@@ -34,10 +34,9 @@ test("renders a skip link before navigation content", async () => {
name: "Skip to main content",
});
const navigation = screen.getAllByRole("navigation")[0];
const mainContent = screen.getByRole("main");
const mainContent = document.getElementById("main-content");
expect(skipToContentLink).toHaveAttribute("href", "#main-content");
expect(mainContent).toHaveAttribute("id", "main-content");
expect(mainContent).toHaveAttribute("tabindex", "-1");
expect(
skipToContentLink.compareDocumentPosition(navigation) &
@@ -5,7 +5,15 @@ import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { Chat } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { fn, spyOn } from "storybook/test";
import {
expect,
fn,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { AgentsPageView } from "./AgentsPageView";
@@ -33,6 +41,57 @@ const defaultModelConfigs: TypesGen.ChatModelConfig[] = [
},
];
const mockAnalyticsSummary: TypesGen.ChatCostSummary = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 1_500_000,
priced_message_count: 12,
unpriced_message_count: 1,
total_input_tokens: 123_456,
total_output_tokens: 654_321,
by_model: [
{
model_config_id: "model-config-1",
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 1_250_000,
message_count: 9,
total_input_tokens: 100_000,
total_output_tokens: 200_000,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Quarterly review",
total_cost_micros: 750_000,
message_count: 5,
total_input_tokens: 60_000,
total_output_tokens: 80_000,
},
],
};
const mockUsageUsers: TypesGen.ChatCostUsersResponse = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 1,
users: [
{
user_id: "user-1",
username: "alice",
name: "Alice Example",
avatar_url: "https://example.com/alice.png",
total_cost_micros: 1_200_000,
message_count: 12,
chat_count: 3,
total_input_tokens: 120_000,
total_output_tokens: 45_000,
},
],
};
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const todayTimestamp = new Date().toISOString();
@@ -111,6 +170,18 @@ const meta: Meta<typeof AgentsPageView> = {
workspaces: [],
count: 0,
});
spyOn(API, "getChatCostSummary").mockResolvedValue(mockAnalyticsSummary);
spyOn(API, "getChatCostUsers").mockResolvedValue(mockUsageUsers);
spyOn(API, "getChatSystemPrompt").mockResolvedValue({
system_prompt: "",
});
spyOn(API, "updateChatSystemPrompt").mockResolvedValue();
spyOn(API, "getUserChatCustomPrompt").mockResolvedValue({
custom_prompt: "",
});
spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({
custom_prompt: "",
});
},
};
@@ -302,3 +373,181 @@ export const WithErrorReasons: Story = {
},
},
};
type ChatCostSummaryCall = [
user: string,
params?: {
start_date?: string;
end_date?: string;
},
];
const getChatCostSummaryCalls = (): ChatCostSummaryCall[] => {
return (
API.getChatCostSummary as typeof API.getChatCostSummary & {
mock: { calls: ChatCostSummaryCall[] };
}
).mock.calls;
};
const openAnalyticsDialog = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Analytics" }));
return screen.findByRole("dialog", { name: "Analytics" });
};
const openSettingsDialog = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Settings" }));
return screen.findByRole("dialog", { name: "Settings" });
};
export const OpensAnalyticsForAdmins: Story = {
args: {
isAgentsAdmin: true,
},
play: async ({ canvasElement }) => {
const dialog = await openAnalyticsDialog(canvasElement);
await expect(dialog).toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "Settings" }),
).not.toBeInTheDocument();
},
};
export const OpensAnalyticsForNonAdmins: Story = {
args: {
isAgentsAdmin: false,
},
play: async ({ canvasElement }) => {
const dialog = await openAnalyticsDialog(canvasElement);
await expect(dialog).toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "Settings" }),
).not.toBeInTheDocument();
},
};
export const OpensSettingsForAdmins: Story = {
args: {
isAgentsAdmin: true,
},
play: async ({ canvasElement }) => {
const dialog = await openSettingsDialog(canvasElement);
await expect(dialog).toBeInTheDocument();
await expect(
within(dialog).getByText(
"Custom instructions that shape how the agent responds in your chats.",
),
).toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "Analytics" }),
).not.toBeInTheDocument();
},
};
export const OpensSettingsForNonAdmins: Story = {
args: {
isAgentsAdmin: false,
},
play: async ({ canvasElement }) => {
const dialog = await openSettingsDialog(canvasElement);
await expect(dialog).toBeInTheDocument();
await expect(
within(dialog).getByText(
"Custom instructions that shape how the agent responds in your chats.",
),
).toBeInTheDocument();
},
};
export const RemountsConfigureDialogWhenReopened: Story = {
args: {
isAgentsAdmin: true,
},
play: async ({ canvasElement }) => {
let dialog = await openSettingsDialog(canvasElement);
await userEvent.click(
within(dialog).getByRole("button", { name: "Usage" }),
);
await waitFor(() => {
expect(
screen.getByText(
"Review deployment chat usage and drill into individual users.",
),
).toBeInTheDocument();
});
await userEvent.click(
within(dialog).getByRole("button", { name: "Close" }),
);
await waitFor(() => {
expect(
screen.queryByRole("dialog", { name: "Settings" }),
).not.toBeInTheDocument();
});
dialog = await openSettingsDialog(canvasElement);
await expect(
within(dialog).getByText(
"Custom instructions that shape how the agent responds in your chats.",
),
).toBeInTheDocument();
expect(
screen.queryByText(
"Review deployment chat usage and drill into individual users.",
),
).not.toBeInTheDocument();
},
};
export const RemountsAnalyticsDialogWhenReopened: Story = {
args: {
isAgentsAdmin: true,
},
play: async ({ canvasElement }) => {
let dialog = await openAnalyticsDialog(canvasElement);
await expect(dialog).toBeInTheDocument();
await waitFor(() => {
expect(getChatCostSummaryCalls().length).toBeGreaterThan(0);
});
const initialCallCount = getChatCostSummaryCalls().length;
const initialEndDates = new Set(
getChatCostSummaryCalls()
.map(([, params]) => params?.end_date)
.filter((endDate): endDate is string => Boolean(endDate)),
);
await userEvent.click(
within(dialog).getByRole("button", { name: "Close" }),
);
await waitFor(() => {
expect(
screen.queryByRole("dialog", { name: "Analytics" }),
).not.toBeInTheDocument();
});
dialog = await openAnalyticsDialog(canvasElement);
await expect(dialog).toBeInTheDocument();
await waitFor(() => {
expect(getChatCostSummaryCalls().length).toBeGreaterThan(
initialCallCount,
);
});
const reopenedCalls = getChatCostSummaryCalls().slice(initialCallCount);
expect(
reopenedCalls.some(([, params]) => {
const endDate = params?.end_date;
return typeof endDate === "string" && !initialEndDates.has(endDate);
}),
).toBe(true);
},
};
+27 -2
View File
@@ -11,7 +11,11 @@ import { pageTitle } from "utils/page";
import { AgentCreateForm, type CreateChatOptions } from "./AgentCreateForm";
import { AgentsSidebar } from "./AgentsSidebar";
import { ChimeButton } from "./ChimeButton";
import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog";
import {
ConfigureAgentsDialog,
type ConfigureAgentsSection,
} from "./ConfigureAgentsDialog";
import { UserAnalyticsDialog } from "./UserAnalyticsDialog";
import { WebPushButton } from "./WebPushButton";
type ChatModelOption = ModelSelectorOption;
@@ -99,6 +103,12 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
} = outletContext;
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
const [configDialogKey, setConfigDialogKey] = useState(0);
const [configureAgentsInitialSection, setConfigureAgentsInitialSection] =
useState<ConfigureAgentsSection>("behavior");
const [isUserAnalyticsDialogOpen, setUserAnalyticsDialogOpen] =
useState(false);
const [analyticsDialogKey, setAnalyticsDialogKey] = useState(0);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<title>{pageTitle("Agents")}</title>
@@ -133,7 +143,15 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
onCollapse={onCollapseSidebar}
onOpenSettings={() => setConfigureAgentsDialogOpen(true)}
onOpenAnalytics={() => {
setAnalyticsDialogKey((key) => key + 1);
setUserAnalyticsDialogOpen(true);
}}
onOpenSettings={() => {
setConfigureAgentsInitialSection("behavior");
setConfigDialogKey((key) => key + 1);
setConfigureAgentsDialogOpen(true);
}}
/>
</div>
@@ -191,10 +209,17 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
</div>
<ConfigureAgentsDialog
key={configDialogKey}
open={isConfigureAgentsDialogOpen}
onOpenChange={setConfigureAgentsDialogOpen}
canManageChatModelConfigs={isAgentsAdmin}
canSetSystemPrompt={isAgentsAdmin}
initialSection={configureAgentsInitialSection}
/>
<UserAnalyticsDialog
key={analyticsDialogKey}
open={isUserAnalyticsDialogOpen}
onOpenChange={setUserAnalyticsDialogOpen}
/>
</div>
);
@@ -19,11 +19,17 @@ import { CoderIcon } from "components/Icons/CoderIcon";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Skeleton } from "components/Skeleton/Skeleton";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useAuthenticated } from "hooks";
import {
AlertTriangleIcon,
ArchiveIcon,
ArchiveRestoreIcon,
BarChart3Icon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
@@ -80,6 +86,7 @@ interface AgentsSidebarProps {
archivedFilter: "active" | "archived";
onArchivedFilterChange?: (filter: "active" | "archived") => void;
onCollapse?: () => void;
onOpenAnalytics?: () => void;
onOpenSettings?: () => void;
}
@@ -572,6 +579,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
archivedFilter,
onArchivedFilterChange,
onCollapse,
onOpenAnalytics,
onOpenSettings,
} = props;
const { agentId, chatId } = useParams<{
@@ -858,6 +866,22 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
/>
</DropdownMenuContent>
</DropdownMenu>
{onOpenAnalytics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
onClick={onOpenAnalytics}
aria-label="Analytics"
className="mr-1"
>
<BarChart3Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Analytics</TooltipContent>
</Tooltip>
)}
{onOpenSettings && (
<button
type="button"
@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type * as TypesGen from "api/typesGenerated";
import { fn } from "storybook/test";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
const buildSummary = (
overrides: Partial<TypesGen.ChatCostSummary> = {},
): TypesGen.ChatCostSummary => ({
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 1_500_000,
priced_message_count: 12,
unpriced_message_count: 0,
total_input_tokens: 123_456,
total_output_tokens: 654_321,
by_model: [
{
model_config_id: "model-config-1",
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 1_250_000,
message_count: 9,
total_input_tokens: 100_000,
total_output_tokens: 200_000,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Quarterly review",
total_cost_micros: 750_000,
message_count: 5,
total_input_tokens: 60_000,
total_output_tokens: 80_000,
},
],
...overrides,
});
const emptySummary = buildSummary({
total_cost_micros: 0,
priced_message_count: 0,
unpriced_message_count: 0,
total_input_tokens: 0,
total_output_tokens: 0,
by_model: [],
by_chat: [],
});
const meta: Meta<typeof ChatCostSummaryView> = {
title: "pages/AgentsPage/ChatCostSummaryView",
component: ChatCostSummaryView,
args: {
summary: undefined,
isLoading: false,
error: undefined,
onRetry: fn(),
loadingLabel: "Loading usage details",
emptyMessage: "No usage details available.",
},
};
export default meta;
type Story = StoryObj<typeof ChatCostSummaryView>;
export const Loading: Story = {
args: {
isLoading: true,
},
};
const ErrorState: Story = {
args: {
error: new globalThis.Error("Failed to fetch"),
onRetry: fn(),
},
};
export { ErrorState as Error };
export const ErrorNonError: Story = {
args: {
error: "string error",
onRetry: fn(),
},
};
export const Empty: Story = {
args: {
summary: emptySummary,
},
};
export const WithData: Story = {
args: {
summary: buildSummary(),
},
};
export const UnpricedWarning: Story = {
args: {
summary: buildSummary({
unpriced_message_count: 2,
}),
},
};
@@ -0,0 +1,210 @@
import { getErrorMessage } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Spinner } from "components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
import { formatCostMicros, formatTokenCount } from "utils/analytics";
interface ChatCostSummaryViewProps {
summary: TypesGen.ChatCostSummary | undefined;
isLoading: boolean;
error: unknown;
onRetry: () => void;
loadingLabel: string;
emptyMessage: string;
}
export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({
summary,
isLoading,
error,
onRetry,
loadingLabel,
emptyMessage,
}) => {
if (isLoading) {
return (
<div
role="status"
aria-label={loadingLabel}
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading />
</div>
);
}
if (error != null) {
return (
<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 usage details.")}
</p>
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
Retry
</Button>
</div>
);
}
if (!summary) {
return null;
}
return (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Total Cost
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatCostMicros(summary.total_cost_micros)}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Input Tokens
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatTokenCount(summary.total_input_tokens)}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Output Tokens
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatTokenCount(summary.total_output_tokens)}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Messages
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{(
summary.priced_message_count + summary.unpriced_message_count
).toLocaleString()}
</p>
</div>
</div>
{summary.unpriced_message_count > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-border-warning bg-surface-warning p-4 text-sm text-content-primary">
<TriangleAlertIcon className="h-5 w-5 shrink-0 text-content-warning" />
<span>
{summary.unpriced_message_count} message
{summary.unpriced_message_count === 1 ? "" : "s"} could not be
priced because model pricing data was unavailable.
</span>
</div>
)}
{summary.by_model.length === 0 && summary.by_chat.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
{emptyMessage}
</p>
) : (
<>
<div className="overflow-x-auto rounded-lg border border-border-default">
<Table className="text-sm" 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>
</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">
{model.provider}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatCostMicros(model.total_cost_micros)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{model.message_count.toLocaleString()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(model.total_input_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(model.total_output_tokens)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="overflow-x-auto rounded-lg border border-border-default">
<Table className="text-sm" aria-label="Cost breakdown by chat">
<TableHeader>
<TableRow className="text-left text-xs font-medium uppercase tracking-wide text-content-secondary">
<TableHead className="px-4 py-3">Chat</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>
</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 chat
</span>
)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatCostMicros(chat.total_cost_micros)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{chat.message_count.toLocaleString()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(chat.total_input_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(chat.total_output_tokens)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</>
);
};
@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type * as TypesGen from "api/typesGenerated";
import { TooltipProvider } from "components/Tooltip/Tooltip";
import { expect, fn, within } from "storybook/test";
import type { ProviderState } from "./ChatModelAdminPanel";
import { ModelsSection } from "./ModelsSection";
const providerState: ProviderState = {
provider: "openai",
label: "OpenAI",
providerConfig: {
id: "provider-config-id",
provider: "openai",
display_name: "OpenAI",
enabled: true,
has_api_key: true,
base_url: undefined,
source: "database",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
modelConfigs: [],
catalogModelCount: 0,
hasManagedAPIKey: true,
hasCatalogAPIKey: true,
hasEffectiveAPIKey: true,
isEnvPreset: false,
baseURL: "",
};
const baseModelConfig: TypesGen.ChatModelConfig = {
id: "model-config-id",
provider: "openai",
model: "gpt-4.1",
display_name: "GPT-4.1",
enabled: true,
is_default: false,
context_limit: 128000,
compression_threshold: 80,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const meta: Meta<typeof ModelsSection> = {
title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection",
component: ModelsSection,
args: {
sectionLabel: "Models",
providerStates: [providerState],
selectedProvider: "openai",
selectedProviderState: providerState,
onSelectedProviderChange: fn(),
modelConfigs: [baseModelConfig],
modelConfigsUnavailable: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
onDeleteModel: fn(async () => undefined),
},
decorators: [
(Story) => (
<TooltipProvider>
<Story />
</TooltipProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof ModelsSection>;
export const ShowsPricingWarning: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("Model pricing is not defined"),
).toBeInTheDocument();
},
};
export const HidesPricingWarningForExplicitZeroPricing: Story = {
args: {
modelConfigs: [
{
...baseModelConfig,
id: "model-config-id-zero-pricing",
model_config: {
cost: {
output_price_per_million_tokens: "0",
},
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.queryByText("Model pricing is not defined"),
).not.toBeInTheDocument();
},
};
@@ -6,6 +6,9 @@ import {
chatProviderConfigsKey,
} from "api/queries/chats";
import type {
ChatCostSummary,
ChatCostUserRollup,
ChatCostUsersResponse,
ChatModelConfig,
ChatModelsResponse,
ChatProviderConfig,
@@ -75,6 +78,73 @@ const chatQueries = [
{ key: chatModelsKey, data: mockChatModels },
];
const buildUsageUser = (
overrides: Partial<ChatCostUserRollup> = {},
): ChatCostUserRollup => ({
user_id: "user-1",
username: "alice",
name: "Alice Example",
avatar_url: "https://example.com/alice.png",
total_cost_micros: 1_200_000,
message_count: 12,
chat_count: 3,
total_input_tokens: 120_000,
total_output_tokens: 45_000,
...overrides,
});
const mockUsageUsers: ChatCostUsersResponse = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 2,
users: [
buildUsageUser(),
buildUsageUser({
user_id: "user-2",
username: "bob",
name: "Bob Example",
avatar_url: "https://example.com/bob.png",
total_cost_micros: 900_000,
message_count: 8,
chat_count: 2,
total_input_tokens: 80_000,
total_output_tokens: 30_000,
}),
],
};
const mockUsageSummary: ChatCostSummary = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 1_200_000,
priced_message_count: 12,
unpriced_message_count: 0,
total_input_tokens: 120_000,
total_output_tokens: 45_000,
by_model: [
{
model_config_id: "model-cfg-1",
display_name: "GPT-4o",
provider: "OpenAI",
model: "gpt-4o",
total_cost_micros: 1_200_000,
message_count: 12,
total_input_tokens: 120_000,
total_output_tokens: 45_000,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Quarterly review",
total_cost_micros: 1_200_000,
message_count: 12,
total_input_tokens: 120_000,
total_output_tokens: 45_000,
},
],
};
const meta: Meta<typeof ConfigureAgentsDialog> = {
title: "pages/AgentsPage/ConfigureAgentsDialog",
component: ConfigureAgentsDialog,
@@ -159,3 +229,15 @@ export const SavesBehaviorPromptAndRestores: Story = {
});
},
};
/** Admin can open the Usage tab and review user chat spend. */
export const UsageTab: Story = {
args: {
initialSection: "usage",
canManageChatModelConfigs: true,
},
beforeEach: () => {
spyOn(API, "getChatCostUsers").mockResolvedValue(mockUsageUsers);
spyOn(API, "getChatCostSummary").mockResolvedValue(mockUsageSummary);
},
};
@@ -1,9 +1,14 @@
import { getErrorMessage } from "api/errors";
import {
chatCostSummary,
chatCostUsers,
chatSystemPrompt,
chatUserCustomPrompt,
updateChatSystemPrompt,
updateUserChatCustomPrompt,
} from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import { AvatarData } from "components/Avatar/AvatarData";
import { Button } from "components/Button/Button";
import {
Dialog,
@@ -13,35 +18,55 @@ import {
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import dayjs from "dayjs";
import { useDebouncedValue } from "hooks/debounce";
import { useClickableTableRow } from "hooks/useClickableTableRow";
import type { LucideIcon } from "lucide-react";
import {
BarChart3Icon,
BoxesIcon,
KeyRoundIcon,
ShieldIcon,
UserIcon,
XIcon,
} from "lucide-react";
import { type FC, type FormEvent, useCallback, useMemo, useState } from "react";
import {
type FC,
type FormEvent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "react-query";
import TextareaAutosize from "react-textarea-autosize";
import { formatCostMicros, formatTokenCount } from "utils/analytics";
import { cn } from "utils/cn";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
import { SectionHeader } from "./SectionHeader";
type ConfigureAgentsSection = "providers" | "models" | "behavior";
export type ConfigureAgentsSection =
| "providers"
| "models"
| "behavior"
| "usage";
type ConfigureAgentsSectionOption = {
id: ConfigureAgentsSection;
@@ -66,6 +91,248 @@ const AdminBadge: FC = () => (
</TooltipProvider>
);
const pageSize = 10;
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>
</TableRow>
);
};
const UsageContent: FC = () => {
const [selectedUser, setSelectedUser] =
useState<TypesGen.ChatCostUserRollup | null>(null);
const [usernameFilter, setUsernameFilter] = useState("");
const debouncedUsername = useDebouncedValue(usernameFilter, 300);
const [page, setPage] = useState(1);
const dateRange = useMemo(() => {
const end = dayjs();
const start = end.subtract(30, "day");
return {
startDate: start.toISOString(),
endDate: end.toISOString(),
rangeLabel: `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`,
};
}, []);
const offset = (page - 1) * pageSize;
const usersQuery = useQuery({
...chatCostUsers({
start_date: dateRange.startDate,
end_date: dateRange.endDate,
username: debouncedUsername || undefined,
limit: pageSize,
offset,
}),
placeholderData: keepPreviousData,
});
const summaryQuery = useQuery({
...chatCostSummary(selectedUser?.user_id ?? "me", {
start_date: dateRange.startDate,
end_date: dateRange.endDate,
}),
enabled: selectedUser !== null,
});
const totalCount = usersQuery.data?.count ?? 0;
const hasPreviousPage = page > 1;
const hasNextPage = offset + pageSize < totalCount;
const header = (
<SectionHeader
label="Usage"
description={
selectedUser
? "Review deployment chat usage for a specific user."
: "Review deployment chat usage and drill into individual users."
}
badge={<AdminBadge />}
action={
selectedUser ? (
<Button
variant="outline"
size="sm"
type="button"
onClick={() => setSelectedUser(null)}
>
← Back to all users
</Button>
) : (
<span className="text-xs text-content-secondary">
{dateRange.rangeLabel}
</span>
)
}
/>
);
if (selectedUser) {
return (
<div className="space-y-6">
{header}
<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.user_id}</div>
<div>{dateRange.rangeLabel}</div>
</div>
</div>
<ChatCostSummaryView
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
error={summaryQuery.error}
onRetry={() => void summaryQuery.refetch()}
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={usernameFilter}
onChange={(value) => {
setUsernameFilter(value);
setPage(1);
}}
placeholder="Filter by username"
aria-label="Filter usage by username"
/>
</div>
{usersQuery.data && (
<PaginationAmount
limit={pageSize}
totalRecords={usersQuery.data.count}
currentOffsetStart={usersQuery.data.count === 0 ? 0 : offset + 1}
paginationUnitLabel="users"
/>
)}
</div>
{usersQuery.isLoading && (
<div
role="status"
aria-label="Loading usage"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersQuery.error != null && (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<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>
)}
{usersQuery.data &&
(usersQuery.data.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>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.data.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={setSelectedUser}
/>
))}
</TableBody>
</Table>
</div>
<PaginationWidgetBase
totalRecords={usersQuery.data.count}
currentPage={page}
pageSize={pageSize}
onPageChange={setPage}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
/>
</>
))}
</div>
);
};
const textareaClassName =
"max-h-[240px] w-full resize-none overflow-y-auto rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30 [scrollbar-width:thin]";
@@ -74,6 +341,7 @@ interface ConfigureAgentsDialogProps {
onOpenChange: (open: boolean) => void;
canManageChatModelConfigs: boolean;
canSetSystemPrompt: boolean;
initialSection?: ConfigureAgentsSection;
}
export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
@@ -81,6 +349,7 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
onOpenChange,
canManageChatModelConfigs,
canSetSystemPrompt,
initialSection = "behavior",
}) => {
const queryClient = useQueryClient();
@@ -156,12 +425,18 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
icon: BoxesIcon,
adminOnly: true,
});
options.push({
id: "usage",
label: "Usage",
icon: BarChart3Icon,
adminOnly: true,
});
}
return options;
}, [canManageChatModelConfigs]);
const [userActiveSection, setUserActiveSection] =
useState<ConfigureAgentsSection>("behavior");
useState<ConfigureAgentsSection>(initialSection);
const activeSection = configureSectionOptions.some(
(s) => s.id === userActiveSection,
@@ -169,12 +444,6 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
? userActiveSection
: (configureSectionOptions[0]?.id ?? "behavior");
useEffect(() => {
if (open) {
setUserActiveSection("behavior");
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="grid h-[min(88dvh,720px)] max-w-4xl grid-cols-1 gap-0 overflow-hidden p-0 md:grid-cols-[220px_minmax(0,1fr)]">
@@ -353,6 +622,9 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
sectionBadge={<AdminBadge />}
/>
)}
{activeSection === "usage" && canManageChatModelConfigs && (
<UsageContent />
)}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,61 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { fn, spyOn } from "storybook/test";
import { UserAnalyticsDialog } from "./UserAnalyticsDialog";
const mockSummary: TypesGen.ChatCostSummary = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 1_500_000,
priced_message_count: 12,
unpriced_message_count: 1,
total_input_tokens: 123_456,
total_output_tokens: 654_321,
by_model: [
{
model_config_id: "model-config-1",
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 1_250_000,
message_count: 9,
total_input_tokens: 100_000,
total_output_tokens: 200_000,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Quarterly review",
total_cost_micros: 750_000,
message_count: 5,
total_input_tokens: 60_000,
total_output_tokens: 80_000,
},
],
};
const meta: Meta<typeof UserAnalyticsDialog> = {
title: "pages/AgentsPage/UserAnalyticsDialog",
component: UserAnalyticsDialog,
decorators: [withAuthProvider],
parameters: {
user: MockUserOwner,
},
beforeEach: () => {
spyOn(API, "getChatCostSummary").mockResolvedValue(mockSummary);
},
};
export default meta;
type Story = StoryObj<typeof UserAnalyticsDialog>;
export const Default: Story = {
args: {
open: true,
onOpenChange: fn(),
},
};
@@ -0,0 +1,94 @@
import { chatCostSummary } from "api/queries/chats";
import { Button } from "components/Button/Button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
import { useAuthContext } from "contexts/auth/AuthProvider";
import dayjs from "dayjs";
import { BarChart3Icon, XIcon } from "lucide-react";
import { type FC, useMemo } from "react";
import { useQuery } from "react-query";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { SectionHeader } from "./SectionHeader";
const createDateRange = () => {
const end = dayjs();
const start = end.subtract(30, "day");
return {
startDate: start.toISOString(),
endDate: end.toISOString(),
rangeLabel: `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`,
};
};
interface UserAnalyticsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const UserAnalyticsDialog: FC<UserAnalyticsDialogProps> = ({
open,
onOpenChange,
}) => {
const { user } = useAuthContext();
const dateRange = useMemo(createDateRange, []);
const summaryQuery = useQuery({
...chatCostSummary(user?.id ?? "me", {
start_date: dateRange.startDate,
end_date: dateRange.endDate,
}),
enabled: open && Boolean(user?.id),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl overflow-hidden p-0">
<DialogHeader className="sr-only">
<DialogTitle>Analytics</DialogTitle>
<DialogDescription>
Review your personal chat usage for the last 30 days.
</DialogDescription>
</DialogHeader>
<div className="flex max-h-[min(88dvh,720px)] min-h-0 flex-col overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="mb-6 flex items-start justify-between gap-4">
<SectionHeader
label="Analytics"
description="Review your personal chat usage and cost breakdowns."
action={
<div className="flex items-center gap-2 text-xs text-content-secondary">
<BarChart3Icon className="h-4 w-4" />
<span>{dateRange.rangeLabel}</span>
</div>
}
/>
<DialogClose asChild>
<Button
variant="subtle"
size="icon-lg"
className="shrink-0 border-none bg-transparent shadow-none hover:bg-surface-tertiary/50"
>
<XIcon className="text-content-secondary" />
<span className="sr-only">Close</span>
</Button>
</DialogClose>
</div>
<ChatCostSummaryView
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
error={summaryQuery.error}
onRetry={() => void summaryQuery.refetch()}
loadingLabel="Loading analytics"
emptyMessage="No usage data for you in this period."
/>
</div>
</DialogContent>
</Dialog>
);
};
+18 -18
View File
@@ -8,7 +8,13 @@ import {
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { screen, waitFor, within } from "@testing-library/react";
import {
createEvent,
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import type { AuditLogsRequest } from "api/typesGenerated";
@@ -85,53 +91,47 @@ describe("AuditPage", () => {
audit_logs: [MockAuditLog],
count: 1,
});
const user = userEvent.setup();
await renderPage();
const row = screen.getByTestId(`audit-log-row-${MockAuditLog.id}`);
const expandableRowButton = within(row).getByRole("button");
expect(expandableRowButton).toHaveAttribute("role", "button");
expect(expandableRowButton).toHaveAttribute("tabindex", "0");
expect(expandableRowButton.tagName).toBe("DIV");
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
expandableRowButton.focus();
await user.keyboard("{Enter}");
fireEvent.keyDown(expandableRowButton, { key: "Enter" });
expect(screen.getAllByText(/ttl:/i)).toHaveLength(2);
await user.keyboard("{Enter}");
fireEvent.keyDown(expandableRowButton, { key: "Enter" });
await waitFor(() => {
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
});
});
it("toggles an expandable audit row with Space", async () => {
it("toggles an expandable audit row with Space and prevents default", async () => {
vi.spyOn(API, "getAuditLogs").mockResolvedValue({
audit_logs: [MockAuditLog],
count: 1,
});
const user = userEvent.setup();
await renderPage();
const row = screen.getByTestId(`audit-log-row-${MockAuditLog.id}`);
const expandableRowButton = within(row).getByRole("button");
const spaceEvent = createEvent.keyDown(expandableRowButton, {
key: " ",
code: "Space",
});
const preventDefaultSpy = vi.spyOn(spaceEvent, "preventDefault");
expect(expandableRowButton).toHaveAttribute("role", "button");
expect(expandableRowButton).toHaveAttribute("tabindex", "0");
expect(expandableRowButton.tagName).toBe("DIV");
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
expandableRowButton.focus();
await user.keyboard(" ");
fireEvent(expandableRowButton, spaceEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(screen.getAllByText(/ttl:/i)).toHaveLength(2);
await user.keyboard(" ");
fireEvent.keyDown(expandableRowButton, { key: " " });
await waitFor(() => {
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { formatCostMicros, formatTokenCount } from "./analytics";
describe("formatCostMicros", () => {
it("formats zero values", () => {
expect(formatCostMicros(0)).toBe("$0.00");
});
it("formats normal values to cents", () => {
expect(formatCostMicros(1_500_000)).toBe("$1.50");
expect(formatCostMicros(123_456)).toBe("$0.12");
});
it("formats sub-cent values with four decimal places", () => {
expect(formatCostMicros(500)).toBe("$0.0005");
});
it("falls back to zero for invalid numeric values", () => {
expect(formatCostMicros("abc")).toBe("$0.00");
expect(formatCostMicros(Number.POSITIVE_INFINITY)).toBe("$0.00");
});
it("formats negative values with the minus sign before the dollar sign", () => {
expect(formatCostMicros(-1_500_000)).toBe("-$1.50");
expect(formatCostMicros(-500)).toBe("-$0.0005");
});
it("avoids confusing four-decimal output when sub-cent values round to one cent", () => {
expect(formatCostMicros(9_999)).toBe("$0.01");
});
it("formats threshold values correctly", () => {
expect(formatCostMicros(10_000)).toBe("$0.01");
expect(formatCostMicros(12_345_678)).toBe("$12.35");
});
it("formats string micros from generated API types", () => {
expect(formatCostMicros("1500000")).toBe("$1.50");
});
});
describe("formatTokenCount", () => {
it("formats zero values", () => {
expect(formatTokenCount(0)).toBe("0");
});
it("formats normal values with locale separators", () => {
expect(formatTokenCount(999)).toBe("999");
expect(formatTokenCount(1_234)).toBe("1,234");
expect(formatTokenCount(999_999)).toBe("999,999");
});
it("formats large values in millions", () => {
expect(formatTokenCount(1_000_000)).toBe("1M");
expect(formatTokenCount(1_500_000)).toBe("1.5M");
expect(formatTokenCount(2_000_000)).toBe("2M");
});
it("rounds million values to one decimal place when needed", () => {
expect(formatTokenCount(1_250_000)).toBe("1.3M");
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Format cost in micros (millionths of a dollar) to a currency string.
* Examples: 0 → "$0.00", 1_500_000 → "$1.50", 123_456 → "$0.12"
*/
export function formatCostMicros(micros: number | string): string {
const microsValue = typeof micros === "string" ? Number(micros) : micros;
if (Number.isNaN(microsValue) || !Number.isFinite(microsValue)) {
return "$0.00";
}
const sign = microsValue < 0 ? "-" : "";
const dollars = Math.abs(microsValue) / 1_000_000;
const rounded = Number(dollars.toFixed(4));
if (rounded > 0 && rounded < 0.01) {
return `${sign}$${dollars.toFixed(4)}`;
}
return `${sign}$${dollars.toFixed(2)}`;
}
/**
* Format a token count to a compact human-readable string.
* Examples: 0 → "0", 1234 → "1,234", 1_500_000 → "1.5M"
*/
export function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) {
const millions = tokens / 1_000_000;
return `${millions % 1 === 0 ? millions.toFixed(0) : millions.toFixed(1)}M`;
}
if (tokens >= 1_000) {
return tokens.toLocaleString("en-US");
}
return tokens.toString();
}