diff --git a/AGENTS.md b/AGENTS.md index c3abfa662b..f0e3d57105 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 26ee6570cc..515681a2a6 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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(url); return response.data; }; + + getChatCostSummary = async ( + user = "me", + params?: ChatCostDateParams, + ): Promise => { + const url = getURLWithSearchParams( + `/api/experimental/chats/cost/${encodeURIComponent(user)}/summary`, + params, + ); + const response = await this.axios.get(url); + return response.data; + }; + + getChatCostUsers = async ( + params?: ChatCostUsersParams, + ): Promise => { + const url = getURLWithSearchParams( + "/api/experimental/chats/cost/users", + params, + ); + const response = await this.axios.get(url); + return response.data; + }; } export type TaskFeedbackRating = "good" | "okay" | "bad"; diff --git a/site/src/api/chatModelOptions.ts b/site/src/api/chatModelOptions.ts index 9e6233ee16..f287b5b0ad 100644 --- a/site/src/api/chatModelOptions.ts +++ b/site/src/api/chatModelOptions.ts @@ -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; } /** diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index b9fb6f7ec8..fc51f4602f 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -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); + }); +}); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index e058050d37..76e1c64010 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -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, +}); diff --git a/site/src/modules/dashboard/DashboardLayout.test.tsx b/site/src/modules/dashboard/DashboardLayout.test.tsx index 0653bc3595..3aaadb4155 100644 --- a/site/src/modules/dashboard/DashboardLayout.test.tsx +++ b/site/src/modules/dashboard/DashboardLayout.test.tsx @@ -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) & diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 03fab07500..dc515dbc91 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -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 = { 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); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index ddd75d68f7..315dc4815b 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -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 = ({ } = outletContext; const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] = useState(false); + const [configDialogKey, setConfigDialogKey] = useState(0); + const [configureAgentsInitialSection, setConfigureAgentsInitialSection] = + useState("behavior"); + const [isUserAnalyticsDialogOpen, setUserAnalyticsDialogOpen] = + useState(false); + const [analyticsDialogKey, setAnalyticsDialogKey] = useState(0); return (
{pageTitle("Agents")} @@ -133,7 +143,15 @@ export const AgentsPageView: FC = ({ 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); + }} />
@@ -191,10 +209,17 @@ export const AgentsPageView: FC = ({ + ); diff --git a/site/src/pages/AgentsPage/AgentsSidebar.tsx b/site/src/pages/AgentsPage/AgentsSidebar.tsx index 99d4856107..6881bc1d78 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.tsx @@ -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 = (props) => { archivedFilter, onArchivedFilterChange, onCollapse, + onOpenAnalytics, onOpenSettings, } = props; const { agentId, chatId } = useParams<{ @@ -858,6 +866,22 @@ export const AgentsSidebar: FC = (props) => { /> + {onOpenAnalytics && ( + + + + + Analytics + + )} {onOpenSettings && ( + + ); + } + + if (!summary) { + return null; + } + + return ( + <> +
+
+

+ Total Cost +

+

+ {formatCostMicros(summary.total_cost_micros)} +

+
+
+

+ Input Tokens +

+

+ {formatTokenCount(summary.total_input_tokens)} +

+
+
+

+ Output Tokens +

+

+ {formatTokenCount(summary.total_output_tokens)} +

+
+
+

+ Messages +

+

+ {( + summary.priced_message_count + summary.unpriced_message_count + ).toLocaleString()} +

+
+
+ + {summary.unpriced_message_count > 0 && ( +
+ + + {summary.unpriced_message_count} message + {summary.unpriced_message_count === 1 ? "" : "s"} could not be + priced because model pricing data was unavailable. + +
+ )} + + {summary.by_model.length === 0 && summary.by_chat.length === 0 ? ( +

+ {emptyMessage} +

+ ) : ( + <> +
+ + + + Model + Provider + Cost + + Messages + + Input + Output + + + + {summary.by_model.map((model) => ( + + + {model.display_name || model.model} + + + {model.provider} + + + {formatCostMicros(model.total_cost_micros)} + + + {model.message_count.toLocaleString()} + + + {formatTokenCount(model.total_input_tokens)} + + + {formatTokenCount(model.total_output_tokens)} + + + ))} + +
+
+ +
+ + + + Chat + Cost + + Messages + + Input + Output + + + + {summary.by_chat.map((chat) => ( + + + {chat.chat_title || ( + + Untitled chat + + )} + + + {formatCostMicros(chat.total_cost_micros)} + + + {chat.message_count.toLocaleString()} + + + {formatTokenCount(chat.total_input_tokens)} + + + {formatTokenCount(chat.total_output_tokens)} + + + ))} + +
+
+ + )} + + ); +}; diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.stories.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.stories.tsx new file mode 100644 index 0000000000..cdb907293d --- /dev/null +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.stories.tsx @@ -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 = { + 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) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx b/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx index 1511256d0d..9c268278a3 100644 --- a/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx +++ b/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx @@ -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 => ({ + 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 = { 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); + }, +}; diff --git a/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx b/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx index f816f37782..8f582103a7 100644 --- a/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx +++ b/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx @@ -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 = () => ( ); +const pageSize = 10; + +const UserRow: FC<{ + user: TypesGen.ChatCostUserRollup; + onSelect: (user: TypesGen.ChatCostUserRollup) => void; +}> = ({ user, onSelect }) => { + const clickableRowProps = useClickableTableRow({ + onClick: () => onSelect(user), + }); + + return ( + + + + + + {formatCostMicros(user.total_cost_micros)} + + + {user.message_count.toLocaleString()} + + + {user.chat_count.toLocaleString()} + + + {formatTokenCount(user.total_input_tokens)} + + + {formatTokenCount(user.total_output_tokens)} + + + ); +}; + +const UsageContent: FC = () => { + const [selectedUser, setSelectedUser] = + useState(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 = ( + } + action={ + selectedUser ? ( + + ) : ( + + {dateRange.rangeLabel} + + ) + } + /> + ); + + if (selectedUser) { + return ( +
+ {header} +
+ +
+
User ID: {selectedUser.user_id}
+
{dateRange.rangeLabel}
+
+
+ + void summaryQuery.refetch()} + loadingLabel="Loading usage details" + emptyMessage="No usage data for this user in the selected period." + /> +
+ ); + } + + return ( +
+ {header} +
+
+ { + setUsernameFilter(value); + setPage(1); + }} + placeholder="Filter by username" + aria-label="Filter usage by username" + /> +
+ {usersQuery.data && ( + + )} +
+ {usersQuery.isLoading && ( +
+ +
+ )} + + {usersQuery.error != null && ( +
+

+ {getErrorMessage(usersQuery.error, "Failed to load usage data.")} +

+ +
+ )} + + {usersQuery.data && + (usersQuery.data.users.length === 0 ? ( +

+ No usage data for this period. +

+ ) : ( + <> +
+ + + + User + + Total Cost + + + Messages + + + Chats + + + Input Tokens + + + Output Tokens + + + + + {usersQuery.data.users.map((user) => ( + + ))} + +
+
+ + + ))} +
+ ); +}; + 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 = ({ @@ -81,6 +349,7 @@ export const ConfigureAgentsDialog: FC = ({ onOpenChange, canManageChatModelConfigs, canSetSystemPrompt, + initialSection = "behavior", }) => { const queryClient = useQueryClient(); @@ -156,12 +425,18 @@ export const ConfigureAgentsDialog: FC = ({ icon: BoxesIcon, adminOnly: true, }); + options.push({ + id: "usage", + label: "Usage", + icon: BarChart3Icon, + adminOnly: true, + }); } return options; }, [canManageChatModelConfigs]); const [userActiveSection, setUserActiveSection] = - useState("behavior"); + useState(initialSection); const activeSection = configureSectionOptions.some( (s) => s.id === userActiveSection, @@ -169,12 +444,6 @@ export const ConfigureAgentsDialog: FC = ({ ? userActiveSection : (configureSectionOptions[0]?.id ?? "behavior"); - useEffect(() => { - if (open) { - setUserActiveSection("behavior"); - } - }, [open]); - return ( @@ -353,6 +622,9 @@ export const ConfigureAgentsDialog: FC = ({ sectionBadge={} /> )} + {activeSection === "usage" && canManageChatModelConfigs && ( + + )} diff --git a/site/src/pages/AgentsPage/UserAnalyticsDialog.stories.tsx b/site/src/pages/AgentsPage/UserAnalyticsDialog.stories.tsx new file mode 100644 index 0000000000..2a7f28b92e --- /dev/null +++ b/site/src/pages/AgentsPage/UserAnalyticsDialog.stories.tsx @@ -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 = { + title: "pages/AgentsPage/UserAnalyticsDialog", + component: UserAnalyticsDialog, + decorators: [withAuthProvider], + parameters: { + user: MockUserOwner, + }, + beforeEach: () => { + spyOn(API, "getChatCostSummary").mockResolvedValue(mockSummary); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + open: true, + onOpenChange: fn(), + }, +}; diff --git a/site/src/pages/AgentsPage/UserAnalyticsDialog.tsx b/site/src/pages/AgentsPage/UserAnalyticsDialog.tsx new file mode 100644 index 0000000000..9794b5c0d5 --- /dev/null +++ b/site/src/pages/AgentsPage/UserAnalyticsDialog.tsx @@ -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 = ({ + 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 ( + + + + Analytics + + Review your personal chat usage for the last 30 days. + + +
+
+ + + {dateRange.rangeLabel} +
+ } + /> + + + +
+ + void summaryQuery.refetch()} + loadingLabel="Loading analytics" + emptyMessage="No usage data for you in this period." + /> + +
+
+ ); +}; diff --git a/site/src/pages/AuditPage/AuditPage.test.tsx b/site/src/pages/AuditPage/AuditPage.test.tsx index 7f0b0cebe2..04be04aa11 100644 --- a/site/src/pages/AuditPage/AuditPage.test.tsx +++ b/site/src/pages/AuditPage/AuditPage.test.tsx @@ -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(); diff --git a/site/src/utils/analytics.test.ts b/site/src/utils/analytics.test.ts new file mode 100644 index 0000000000..7d8326a8b8 --- /dev/null +++ b/site/src/utils/analytics.test.ts @@ -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"); + }); +}); diff --git a/site/src/utils/analytics.ts b/site/src/utils/analytics.ts new file mode 100644 index 0000000000..ac7caae32e --- /dev/null +++ b/site/src/utils/analytics.ts @@ -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(); +}