mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat(site/src): filter spend by provider, model, and client (#29632)
Wires the provider, model, and client filter menus shared with the sessions page into the spend page, in the same order as the table columns. Their options come from deployment-wide AI Gateway endpoints, so only viewers with `viewAnyAIBridgeInterception` get the menus; for anyone else the URL parameters are ignored and no option request is made. ## Why Organization-scoped filter options need a new endpoint and are a follow-up; gating on the deployment-wide permission keeps the page open to organization readers without leaking filter options they cannot list. ## What to scrutinize - `shows spend without dimension filters to viewers who cannot read AI sessions` asserts no provider, model, or client request and no `provider_name` in the report request. - The pending-report test drives the offered presets and checks their request boundaries against the retained cutoff. The keyboard test applies a second range after the first request without moving focus back to the trigger. - The shared model menu sends raw identifiers through `model`, introduced in #29627. Quoting and SQL LIKE escaping no longer live in the frontend. ## Validation `pnpm test src/pages/AISettingsPage/SpendPage/SpendPage.test.tsx src/pages/AIBridgePage/filters/ModelFilter.test.tsx`, `pnpm test:storybook` on `SpendPage.stories.tsx` (`ProviderMenu`, `FilteredByProvider`), repository pre-commit. ## Delivery record Published head `ece31d4795d` passed per-layer integration checks and commit hooks. The composed head `d899228f798` passed format, check, full frontend lint, 125 unit tests, and 86 story tests. Pre-push hooks were skipped as requested. See #28964 for audit findings, their disposition, and historical UAT limits. No exact-head live UAT or new independent re-review is claimed. ## Stack context PR 8 of 9. The stack adds a **User spend** page under **Admin settings** > **AI** > **User spend**: total and per-user AI Gateway spend for one organization over a reporting period, read from `GET /api/v2/organizations/{organization}/ai/spend/users` (#29129, merged). It is #28964 split into one-concern PRs. Historical UAT covers earlier heads only; live UAT has not been rerun after these review fixes. Bottom to top: DateRangePicker limits, shared dimension badge, API client method, spend table, page view, page container, reporting period, dimension filters, route and navigation (#28964). > Xum prepared this PR on behalf of @ibetitsmike.
This commit is contained in:
+3
-1
@@ -3065,7 +3065,9 @@ class ApiMethods {
|
||||
await this.axios.put<void>("/api/v2/notifications/inbox/mark-all-as-read");
|
||||
};
|
||||
|
||||
getAIBridgeModels = async (options: SearchParamOptions) => {
|
||||
getAIBridgeModels = async (
|
||||
options: TypesGen.Pagination & { model?: string },
|
||||
) => {
|
||||
const url = getURLWithSearchParams(`${aiGatewayPath}/models`, options);
|
||||
|
||||
const response = await this.axios.get<string[]>(url);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { FC } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { API } from "#/api/api";
|
||||
import { render } from "#/testHelpers/renderHelpers";
|
||||
import { ModelFilter, useModelFilterMenu } from "./ModelFilter";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const bedrockModel = "us.anthropic.claude-3-5-sonnet-20241022-v2:0";
|
||||
|
||||
const ModelFilterHarness: FC<{ value?: string }> = ({ value }) => {
|
||||
const menu = useModelFilterMenu({ value, onChange: vi.fn(), enabled: true });
|
||||
return <ModelFilter menu={menu} />;
|
||||
};
|
||||
|
||||
it("looks up the selected model by its literal identifier", async () => {
|
||||
const modelsSpy = vi
|
||||
.spyOn(API, "getAIBridgeModels")
|
||||
.mockResolvedValue([bedrockModel]);
|
||||
render(<ModelFilterHarness value={bedrockModel} />);
|
||||
await waitFor(() =>
|
||||
expect(modelsSpy).toHaveBeenCalledWith({ model: bedrockModel, limit: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("searches typed text as a literal model prefix", async () => {
|
||||
const user = userEvent.setup();
|
||||
const modelsSpy = vi.spyOn(API, "getAIBridgeModels").mockResolvedValue([]);
|
||||
render(<ModelFilterHarness />);
|
||||
await user.click(screen.getByRole("button", { name: "Select model" }));
|
||||
await user.type(await screen.findByRole("combobox"), "gpt_4%:");
|
||||
await waitFor(() =>
|
||||
expect(modelsSpy).toHaveBeenCalledWith({ model: "gpt_4%:", limit: 25 }),
|
||||
);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export const useModelFilterMenu = ({
|
||||
id: "model",
|
||||
getSelectedOption: async () => {
|
||||
const modelsRes = await API.getAIBridgeModels({
|
||||
q: value,
|
||||
model: value,
|
||||
limit: 1,
|
||||
});
|
||||
const firstModel = modelsRes.at(0);
|
||||
@@ -36,7 +36,7 @@ export const useModelFilterMenu = ({
|
||||
},
|
||||
getOptions: async (query) => {
|
||||
const modelsRes = await API.getAIBridgeModels({
|
||||
q: query,
|
||||
model: query,
|
||||
limit: 25,
|
||||
});
|
||||
return modelsRes.map((model) => ({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { API } from "#/api/api";
|
||||
import type { OrganizationAISpendUser } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockAIProviders,
|
||||
MockOrganization,
|
||||
MockOrganization2,
|
||||
MockOrganizationAISpendReport,
|
||||
@@ -55,6 +56,7 @@ const meta = {
|
||||
args: { now: fixedNow.toDate() },
|
||||
parameters: {
|
||||
user: MockUserMember,
|
||||
permissions: { viewAnyAIBridgeInterception: true },
|
||||
features: ["aibridge"],
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/ai/settings/spend" },
|
||||
@@ -83,6 +85,9 @@ const meta = {
|
||||
),
|
||||
}),
|
||||
);
|
||||
spyOn(API, "getAIBridgeProviders").mockResolvedValue(MockAIProviders);
|
||||
spyOn(API, "getAIBridgeClients").mockResolvedValue(["Claude Code"]);
|
||||
spyOn(API, "getAIBridgeModels").mockResolvedValue(["gpt-4o"]);
|
||||
},
|
||||
} satisfies Meta<typeof SpendPage>;
|
||||
export default meta;
|
||||
@@ -101,6 +106,48 @@ export const BudgetPeriod: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ProviderMenu: Story = {
|
||||
parameters: { reactRouter: explicitRange },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Select provider" }),
|
||||
);
|
||||
await screen.findByRole("option", { name: /OpenAI/ });
|
||||
},
|
||||
};
|
||||
|
||||
export const FilteredByProvider: Story = {
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
path: "/ai/settings/spend",
|
||||
searchParams: {
|
||||
startDate: "2026-02-10T00:00:00.000Z",
|
||||
endDate: "2026-03-12T00:00:00.000Z",
|
||||
provider_name: "openai",
|
||||
},
|
||||
},
|
||||
routing,
|
||||
}),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getOrganizationAISpendUsers").mockResolvedValue({
|
||||
...MockOrganizationAISpendReport,
|
||||
count: 3,
|
||||
totals: { cost_micros: 27_000_000, unpriced_usage_count: 0 },
|
||||
users: [
|
||||
{ ...mockSpendUsers[0], providers: ["openai"] },
|
||||
{ ...mockSpendUsers[3], providers: ["openai"] },
|
||||
{ ...mockSpendUsers[6], providers: ["openai"] },
|
||||
],
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await within(canvasElement).findByRole("table", { name: "Spend by user" });
|
||||
},
|
||||
};
|
||||
|
||||
// Without a URL range the server narrows the budget period to retention, and
|
||||
// the endpoint rejects any explicit start before that cutoff.
|
||||
export const RetentionLimitedPicker: Story = {
|
||||
|
||||
@@ -10,7 +10,9 @@ import type {
|
||||
OrganizationAISpendUser,
|
||||
} from "#/api/typesGenerated";
|
||||
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
|
||||
import type { Permissions } from "#/modules/permissions";
|
||||
import {
|
||||
MockAIProviders,
|
||||
MockEntitlements,
|
||||
MockNoPermissions,
|
||||
MockOrganization,
|
||||
@@ -23,10 +25,15 @@ import { renderHookWithAuth } from "#/testHelpers/hooks";
|
||||
import { renderWithRouter } from "#/testHelpers/renderHelpers";
|
||||
import SpendPage from "./SpendPage";
|
||||
|
||||
const sessionViewerPermissions: Permissions = {
|
||||
...MockNoPermissions,
|
||||
viewAnyAIBridgeInterception: true,
|
||||
};
|
||||
const auth = { permissions: sessionViewerPermissions };
|
||||
vi.mock("#/hooks/useAuthenticated", () => ({
|
||||
useAuthenticated: () => ({
|
||||
user: MockUserMember,
|
||||
permissions: MockNoPermissions,
|
||||
permissions: auth.permissions,
|
||||
}),
|
||||
}));
|
||||
vi.mock("#/modules/dashboard/useDashboard", () => ({
|
||||
@@ -42,6 +49,7 @@ vi.mock("#/modules/dashboard/useDashboard", () => ({
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
auth.permissions = sessionViewerPermissions;
|
||||
});
|
||||
|
||||
const fixedNow = dayjs("2026-03-12T12:00:00Z");
|
||||
@@ -54,7 +62,7 @@ const initialSearch = new URLSearchParams({
|
||||
endDate: period.period_end,
|
||||
}).toString();
|
||||
|
||||
function mockSpendApi() {
|
||||
function mockSpendApi(report: Partial<OrganizationAISpendReport> = {}) {
|
||||
const users: OrganizationAISpendUser[] = Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => ({
|
||||
@@ -83,6 +91,7 @@ function mockSpendApi() {
|
||||
params.offset ?? 0,
|
||||
(params.offset ?? 0) + (params.limit ?? 10),
|
||||
),
|
||||
...report,
|
||||
});
|
||||
const spendSpy = vi
|
||||
.spyOn(API, "getOrganizationAISpendUsers")
|
||||
@@ -90,8 +99,14 @@ function mockSpendApi() {
|
||||
return { spendSpy, buildReport };
|
||||
}
|
||||
|
||||
function renderSpend(search = initialSearch) {
|
||||
const { spendSpy, buildReport } = mockSpendApi();
|
||||
function renderSpend(
|
||||
search = initialSearch,
|
||||
report: Partial<OrganizationAISpendReport> = {},
|
||||
) {
|
||||
const { spendSpy, buildReport } = mockSpendApi(report);
|
||||
vi.spyOn(API, "getAIBridgeProviders").mockResolvedValue(MockAIProviders);
|
||||
vi.spyOn(API, "getAIBridgeClients").mockResolvedValue(["Claude Code"]);
|
||||
vi.spyOn(API, "getAIBridgeModels").mockResolvedValue(["gpt-4o"]);
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
@@ -196,6 +211,121 @@ it("applies a date preset and resets pagination", async () => {
|
||||
expect(searchParam(router, "page")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the retention bound while a filtered report is pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
const retentionStart = fixedNow.subtract(10, "day");
|
||||
const { spendSpy } = renderSpend(initialSearch, {
|
||||
retention_start: retentionStart.toISOString(),
|
||||
});
|
||||
await screen.findByRole("table", { name: "Spend by user" });
|
||||
|
||||
// Leave the refiltered report pending so its retention bound never arrives.
|
||||
spendSpy.mockImplementationOnce(() => new Promise(() => {}));
|
||||
await user.click(screen.getByRole("button", { name: "Select provider" }));
|
||||
await user.click(await screen.findByRole("option", { name: /OpenAI/ }));
|
||||
await waitFor(() =>
|
||||
expect(spendSpy).toHaveBeenCalledWith(
|
||||
MockOrganization.id,
|
||||
expect.objectContaining({ provider_name: "openai" }),
|
||||
),
|
||||
);
|
||||
|
||||
// The picker stays usable with the bound the first report delivered, so
|
||||
// every preset it offers requests a period that starts within retention.
|
||||
const requestsBeforePicking = spendSpy.mock.calls.length;
|
||||
const picker = screen.getByRole("button", {
|
||||
name: /Feb 10, 2026.*Mar 11, 2026/,
|
||||
});
|
||||
await user.click(picker);
|
||||
const offered = screen
|
||||
.getAllByRole("button", { name: /^Last \d+ days$/ })
|
||||
.map((preset) => preset.textContent ?? "");
|
||||
for (const label of offered) {
|
||||
await user.click(screen.getByRole("button", { name: label }));
|
||||
await waitFor(() =>
|
||||
expect(spendSpy.mock.calls.length).toBeGreaterThan(requestsBeforePicking),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Mar \d+, 2026.*Mar 12, 2026/ }),
|
||||
);
|
||||
}
|
||||
const presetRequests = spendSpy.mock.calls.slice(requestsBeforePicking);
|
||||
expect(presetRequests).toHaveLength(offered.length);
|
||||
expect(presetRequests.length).toBeGreaterThan(0);
|
||||
for (const [, params] of presetRequests) {
|
||||
expect(params.provider_name).toBe("openai");
|
||||
expect(dayjs(params.period_start).isBefore(retentionStart)).toBe(false);
|
||||
}
|
||||
expect(spendSpy).not.toHaveBeenCalledWith(
|
||||
MockOrganization.id,
|
||||
expect.objectContaining({
|
||||
period_start: fixedNow.subtract(29, "day").startOf("day").toISOString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies a second range from the keyboard after the first one resolves", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { spendSpy } = renderSpend();
|
||||
await screen.findByRole("table", { name: "Spend by user" });
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Feb 10, 2026.*Mar 11, 2026/ }),
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: "Last 7 days" }));
|
||||
await waitFor(() =>
|
||||
expect(spendSpy).toHaveBeenCalledWith(
|
||||
MockOrganization.id,
|
||||
expect.objectContaining({
|
||||
period_start: fixedNow.subtract(6, "day").startOf("day").toISOString(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await screen.findByRole("button", { name: /Mar 6, 2026.*Mar 12, 2026/ });
|
||||
|
||||
// Focus returned to the picker when its popover closed, so Enter reopens it.
|
||||
await user.keyboard("{Enter}");
|
||||
await user.click(await screen.findByRole("button", { name: "Yesterday" }));
|
||||
await waitFor(() =>
|
||||
expect(spendSpy).toHaveBeenCalledWith(
|
||||
MockOrganization.id,
|
||||
expect.objectContaining({
|
||||
period_start: fixedNow.subtract(1, "day").startOf("day").toISOString(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows spend without dimension filters to viewers who cannot read AI sessions", async () => {
|
||||
auth.permissions = MockNoPermissions;
|
||||
const { spendSpy } = renderSpend(`${initialSearch}&provider_name=openai`);
|
||||
await screen.findByRole("table", { name: "Spend by user" });
|
||||
expect(API.getAIBridgeProviders).not.toHaveBeenCalled();
|
||||
expect(API.getAIBridgeModels).not.toHaveBeenCalled();
|
||||
expect(API.getAIBridgeClients).not.toHaveBeenCalled();
|
||||
expect(spendSpy).toHaveBeenCalledWith(
|
||||
MockOrganization.id,
|
||||
expect.objectContaining(period),
|
||||
);
|
||||
expect(spendSpy.mock.calls[0][1]).not.toHaveProperty("provider_name");
|
||||
});
|
||||
|
||||
it("applies the provider filter and resets pagination", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router, spendSpy } = renderSpend(`${initialSearch}&page=2`);
|
||||
await screen.findByRole("table", { name: "Spend by user" });
|
||||
await user.click(screen.getByRole("button", { name: "Select provider" }));
|
||||
await user.click(await screen.findByRole("option", { name: /OpenAI/ }));
|
||||
await waitFor(() =>
|
||||
expect(spendSpy).toHaveBeenCalledWith(
|
||||
MockOrganization.id,
|
||||
expect.objectContaining({ provider_name: "openai", offset: 0 }),
|
||||
),
|
||||
);
|
||||
expect(searchParam(router, "provider_name")).toBe("openai");
|
||||
expect(searchParam(router, "page")).toBeNull();
|
||||
});
|
||||
|
||||
it("requests the next page offset", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router, spendSpy } = renderSpend();
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { useClientFilterMenu } from "#/pages/AIBridgePage/filters/ClientFilter";
|
||||
import { useModelFilterMenu } from "#/pages/AIBridgePage/filters/ModelFilter";
|
||||
import { useProviderFilterMenu } from "#/pages/AIBridgePage/filters/ProviderFilter";
|
||||
import { getAIBridgePermissions } from "#/pages/AIBridgePage/getAIBridgePermissions";
|
||||
import {
|
||||
modelOrganizationSearchParam,
|
||||
@@ -27,6 +30,11 @@ import { SpendPageView } from "./SpendPageView";
|
||||
const startDateSearchParam = "startDate";
|
||||
const endDateSearchParam = "endDate";
|
||||
|
||||
type SpendDimensions = Pick<
|
||||
OrganizationAISpendFilter,
|
||||
"provider_name" | "client" | "model"
|
||||
>;
|
||||
|
||||
// Local midnight of the UTC calendar date that the instant falls on.
|
||||
const localDayOf = (instant: Date): Date =>
|
||||
new Date(
|
||||
@@ -98,6 +106,9 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => {
|
||||
permissions,
|
||||
);
|
||||
const isSpendAvailable = isEntitled && isEnabled;
|
||||
// The provider, model, and client options come from deployment-wide AI
|
||||
// Gateway endpoints, so only viewers of every session get those filters.
|
||||
const canFilterDimensions = permissions.viewAnyAIBridgeInterception;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -133,6 +144,31 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => {
|
||||
? undefined
|
||||
: organizationSelection.organization;
|
||||
|
||||
const dimensions: SpendDimensions = canFilterDimensions
|
||||
? {
|
||||
provider_name: searchParams.get("provider_name") || undefined,
|
||||
client: searchParams.get("client") || undefined,
|
||||
model: searchParams.get("model") || undefined,
|
||||
}
|
||||
: {};
|
||||
const filterMenus = {
|
||||
provider: useProviderFilterMenu({
|
||||
value: dimensions.provider_name,
|
||||
onChange: (option) => setFilterParams({ provider_name: option?.value }),
|
||||
enabled: isSpendAvailable && canFilterDimensions,
|
||||
}),
|
||||
client: useClientFilterMenu({
|
||||
value: dimensions.client,
|
||||
onChange: (option) => setFilterParams({ client: option?.value }),
|
||||
enabled: isSpendAvailable && canFilterDimensions,
|
||||
}),
|
||||
model: useModelFilterMenu({
|
||||
value: dimensions.model,
|
||||
onChange: (option) => setFilterParams({ model: option?.value }),
|
||||
enabled: isSpendAvailable && canFilterDimensions,
|
||||
}),
|
||||
};
|
||||
|
||||
const startDateParam = searchParams.get(startDateSearchParam)?.trim() ?? "";
|
||||
const endDateParam = searchParams.get(endDateSearchParam)?.trim() ?? "";
|
||||
|
||||
@@ -161,6 +197,7 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => {
|
||||
period_start: dateRange.startDate.toISOString(),
|
||||
period_end: dateRange.endDate.toISOString(),
|
||||
}),
|
||||
...dimensions,
|
||||
};
|
||||
|
||||
// DateRangePicker already emits exclusive API boundaries (midnight after
|
||||
@@ -217,7 +254,7 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => {
|
||||
minDate={minDate}
|
||||
isRetentionLoading={retention === undefined && reportQuery.isLoading}
|
||||
onDateRangeChange={onDateRangeChange}
|
||||
filterMenus={undefined}
|
||||
filterMenus={canFilterDimensions ? filterMenus : undefined}
|
||||
reportQuery={reportQuery}
|
||||
/>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user