mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user