diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx new file mode 100644 index 0000000000..92fd178506 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx @@ -0,0 +1,133 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn, userEvent, within } from "storybook/test"; +import { MockMenu } from "#/components/Filter/storyHelpers"; +import { + mockInitialRenderResult, + mockSuccessResult, +} from "#/components/PaginationWidget/PaginationContainer.mocks"; +import { + MockOrganization, + MockOrganization2, + MockOrganizationAISpendReport, +} from "#/testHelpers/entities"; +import type { SpendReportQuery } from "./components/SpendUsersTable"; +import { SpendPageView } from "./SpendPageView"; + +const mockReportQuery = { + ...mockSuccessResult, + totalRecords: MockOrganizationAISpendReport.count, + data: MockOrganizationAISpendReport, + isLoading: false, + isFetching: false, + error: null, + refetch: fn(), +} satisfies SpendReportQuery; + +const mockPendingReportQuery = { + ...mockInitialRenderResult, + data: undefined, + isLoading: true, + isFetching: true, + error: null, + refetch: fn(), +} satisfies SpendReportQuery; + +const meta = { + title: "pages/AISettingsPage/SpendPage/SpendPageView", + component: SpendPageView, + args: { + isEntitled: true, + isEnabled: true, + now: new Date("2026-03-12T12:00:00Z"), + organizations: [MockOrganization, MockOrganization2], + organization: MockOrganization, + onOrganizationChange: fn(), + isOrganizationsLoading: false, + organizationsError: null, + dateRange: { + startDate: new Date("2026-02-10T00:00:00Z"), + endDate: new Date("2026-03-12T00:00:00Z"), + }, + minDate: new Date("2026-01-12T00:00:00Z"), + onDateRangeChange: fn(), + filterMenus: { provider: MockMenu, client: MockMenu, model: MockMenu }, + reportQuery: mockReportQuery, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Paywall: Story = { + args: { isEntitled: false }, +}; + +export const NotEnabled: Story = { + args: { isEnabled: false }, +}; + +export const OrganizationsLoading: Story = { + args: { isOrganizationsLoading: true }, +}; + +export const OrganizationsError: Story = { + args: { + organizations: [], + organization: undefined, + organizationsError: new Error("Unable to load organizations"), + }, +}; + +export const OrganizationsRefetchError: Story = { + args: { organizationsError: new Error("Unable to refresh organizations") }, +}; + +export const NoPermittedOrganizations: Story = { + args: { organizations: [], organization: undefined }, +}; + +export const Loading: Story = { + args: { + dateRange: undefined, + reportQuery: mockPendingReportQuery, + }, +}; + +export const Users: Story = {}; + +export const WithoutDimensionFilters: Story = { + args: { filterMenus: undefined }, +}; + +export const SingleOrganization: Story = { + args: { organizations: [MockOrganization] }, +}; + +export const OrganizationMenu: Story = { + play: async ({ canvasElement }) => { + await userEvent.click( + within(canvasElement).getByRole("button", { + name: `Organization ${MockOrganization.display_name}`, + }), + ); + }, +}; + +export const RequestedOrganizationDenied: Story = { + args: { organization: undefined }, +}; + +export const Mobile: Story = { + globals: { viewport: { value: "mobile2", isRotated: false } }, +}; + +// The content width a 1024px viewport leaves beside the settings sidebar. +export const NarrowContainer: Story = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.test.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.test.tsx new file mode 100644 index 0000000000..a4c8aca524 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.test.tsx @@ -0,0 +1,69 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { mockInitialRenderResult } from "#/components/PaginationWidget/PaginationContainer.mocks"; +import { MockOrganization, MockOrganization2 } from "#/testHelpers/entities"; +import { render } from "#/testHelpers/renderHelpers"; +import type { SpendReportQuery } from "./components/SpendUsersTable"; +import { SpendPageView } from "./SpendPageView"; + +const pendingReportQuery = { + ...mockInitialRenderResult, + data: undefined, + isLoading: true, + isFetching: true, + error: null, + refetch: vi.fn(), +} satisfies SpendReportQuery; + +const renderView = (organization: typeof MockOrganization | undefined) => { + const onOrganizationChange = vi.fn(); + render( + , + ); + return { onOrganizationChange }; +}; + +it("reports the organization picked from the switcher", async () => { + const user = userEvent.setup(); + const { onOrganizationChange } = renderView(MockOrganization); + + await user.click( + screen.getByRole("button", { + name: `Organization ${MockOrganization.display_name}`, + }), + ); + await user.click( + await screen.findByRole("option", { name: /My Organization 2/ }), + ); + + expect(onOrganizationChange).toHaveBeenCalledWith(MockOrganization2); +}); + +it("reports the organization picked to recover from a denied one", async () => { + const user = userEvent.setup(); + const { onOrganizationChange } = renderView(undefined); + + await user.click( + screen.getByRole("button", { name: /Select an organization/ }), + ); + await user.click( + await screen.findByRole("option", { name: /My Organization 2/ }), + ); + + expect(onOrganizationChange).toHaveBeenCalledWith(MockOrganization2); +}); diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx new file mode 100644 index 0000000000..222e9431de --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx @@ -0,0 +1,144 @@ +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Alert } from "#/components/Alert/Alert"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker"; +import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { Loader } from "#/components/Loader/Loader"; +import { OrganizationAutocomplete } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; +import { PremiumPaywallAIGovernance } from "#/modules/paywall/PremiumPaywallAIGovernance"; +import { AIBridgeSetupAlert } from "#/pages/AIBridgePage/AIBridgeSetupAlert"; +import { type SpendFilterMenus, SpendFilters } from "./components/SpendFilters"; +import { + type SpendReportQuery, + SpendUsersTable, +} from "./components/SpendUsersTable"; + +type SpendPageViewProps = { + isEntitled: boolean; + isEnabled: boolean; + now: Date | undefined; + organizations: readonly TypesGen.Organization[]; + organization: TypesGen.Organization | undefined; + onOrganizationChange: (organization: TypesGen.Organization) => void; + isOrganizationsLoading: boolean; + organizationsError: unknown; + dateRange: DateRangeValue | undefined; + minDate: Date | undefined; + onDateRangeChange: (value: DateRangeValue) => void; + filterMenus: SpendFilterMenus | undefined; + reportQuery: SpendReportQuery; +}; + +export const SpendPageView: FC = ({ + isEntitled, + isEnabled, + ...contentProps +}) => { + if (!isEntitled) { + return ( + + ); + } + + if (!isEnabled) { + return ; + } + + return ( +
+ + User spend + + Monitor total and per-user AI Gateway spend for the selected + organization. + + + +
+ ); +}; + +type SpendPageContentProps = Omit< + SpendPageViewProps, + "isEntitled" | "isEnabled" +>; + +const SpendPageContent: FC = ({ + now, + organizations, + organization, + onOrganizationChange, + isOrganizationsLoading, + organizationsError, + dateRange, + minDate, + onDateRangeChange, + filterMenus, + reportQuery, +}) => { + if (isOrganizationsLoading) { + return ; + } + + if (organizations.length === 0) { + return organizationsError != null ? ( + + ) : ( + + ); + } + + const refetchErrorAlert = organizationsError != null && ( + + ); + + if (organization === undefined) { + return ( + <> + {refetchErrorAlert} + { + if (next) { + onOrganizationChange(next); + } + }} + /> + + This organization is unavailable, or you don't have access to it. + + + ); + } + + return ( + <> + {refetchErrorAlert} + + + + ); +}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/SpendFilters.tsx b/site/src/pages/AISettingsPage/SpendPage/components/SpendFilters.tsx new file mode 100644 index 0000000000..36951a6ab2 --- /dev/null +++ b/site/src/pages/AISettingsPage/SpendPage/components/SpendFilters.tsx @@ -0,0 +1,104 @@ +import { CalendarIcon } from "lucide-react"; +import type { FC } from "react"; +import { MaxAISpendPeriodDays, type Organization } from "#/api/typesGenerated"; +import { + DateRangePicker, + type DateRangeValue, +} from "#/components/DateRangePicker/DateRangePicker"; +import { + getOrganizationLabel, + OrganizationAutocomplete, +} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { Skeleton } from "#/components/Skeleton/Skeleton"; +import { + ClientFilter, + type ClientFilterMenu, +} from "#/pages/AIBridgePage/filters/ClientFilter"; +import { + ModelFilter, + type ModelFilterMenu, +} from "#/pages/AIBridgePage/filters/ModelFilter"; +import { + ProviderFilter, + type ProviderFilterMenu, +} from "#/pages/AIBridgePage/filters/ProviderFilter"; + +const FILTER_WIDTH = 150; + +export type SpendFilterMenus = { + provider: ProviderFilterMenu; + model: ModelFilterMenu; + client: ClientFilterMenu; +}; + +type SpendFiltersProps = { + organizations: readonly Organization[]; + organization: Organization; + onOrganizationChange: (organization: Organization) => void; + menus: SpendFilterMenus | undefined; + now: Date | undefined; + dateRange: DateRangeValue | undefined; + minDate: Date | undefined; + isReportLoading: boolean; + onDateRangeChange: (value: DateRangeValue) => void; +}; + +export const SpendFilters: FC = ({ + organizations, + organization, + onOrganizationChange, + menus, + now, + dateRange, + minDate, + isReportLoading, + onDateRangeChange, +}) => { + return ( +
+ {organizations.length > 1 && ( + { + if (next) { + onOrganizationChange(next); + } + }} + /> + )} + {menus && ( + <> + + + + + )} + {isReportLoading ? ( + // The retention bound arrives with the report, so a picker shown + // before it could offer days the server rejects. + + ) : dateRange ? ( + + ) : ( +
+ + Current budget period +
+ )} +
+ ); +};