mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat(site/src/pages/AISettingsPage/SpendPage): compose the spend page view (#29629)
Adds `SpendPageView`, the presentational page: paywall and not-enabled states, the organizations loading, error, and empty states, a warning when the requested organization is unavailable, and the filters row (`SpendFilters`: organization picker hidden with a single organization, optional provider, model, and client menus, and the date range picker or a non-interactive **Current budget period** label when no explicit range is applied). ## Why Keeping the view stateless lets its stories cover every page-level state without network mocks; the container PR above wires the data. ## What to scrutinize - `SpendFilters` passes `MaxAISpendPeriodDays` and `minDate` to the picker. This layer shows a skeleton while the report is loading; #29631 separates the initial retention-bound load from subsequent report loads so the picker stays mounted. The container decides when `dateRange` is undefined. - `SpendPageView.stories.tsx` holds only page-level states; table states moved to the table PR below. ## Validation `pnpm test:storybook` on `SpendPageView.stories.tsx`, repository pre-commit. ## Delivery record Published head `c462e135906` 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 5 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:
@@ -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<typeof SpendPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SpendPageView>;
|
||||
|
||||
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) => (
|
||||
<div className="w-[580px]">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -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(
|
||||
<SpendPageView
|
||||
isEntitled
|
||||
isEnabled
|
||||
now={new Date("2026-03-12T12:00:00Z")}
|
||||
organizations={[MockOrganization, MockOrganization2]}
|
||||
organization={organization}
|
||||
onOrganizationChange={onOrganizationChange}
|
||||
isOrganizationsLoading={false}
|
||||
organizationsError={null}
|
||||
dateRange={undefined}
|
||||
minDate={undefined}
|
||||
onDateRangeChange={vi.fn()}
|
||||
filterMenus={undefined}
|
||||
reportQuery={pendingReportQuery}
|
||||
/>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
@@ -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<SpendPageViewProps> = ({
|
||||
isEntitled,
|
||||
isEnabled,
|
||||
...contentProps
|
||||
}) => {
|
||||
if (!isEntitled) {
|
||||
return (
|
||||
<PremiumPaywallAIGovernance variant="governance" source="ai_governance" />
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEnabled) {
|
||||
return <AIBridgeSetupAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[1100px] flex-col gap-4">
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>User spend</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Monitor total and per-user AI Gateway spend for the selected
|
||||
organization.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
<SpendPageContent {...contentProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SpendPageContentProps = Omit<
|
||||
SpendPageViewProps,
|
||||
"isEntitled" | "isEnabled"
|
||||
>;
|
||||
|
||||
const SpendPageContent: FC<SpendPageContentProps> = ({
|
||||
now,
|
||||
organizations,
|
||||
organization,
|
||||
onOrganizationChange,
|
||||
isOrganizationsLoading,
|
||||
organizationsError,
|
||||
dateRange,
|
||||
minDate,
|
||||
onDateRangeChange,
|
||||
filterMenus,
|
||||
reportQuery,
|
||||
}) => {
|
||||
if (isOrganizationsLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return organizationsError != null ? (
|
||||
<ErrorAlert error={organizationsError} />
|
||||
) : (
|
||||
<EmptyState
|
||||
isCompact
|
||||
message="You don't have access to any organization's AI spend."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const refetchErrorAlert = organizationsError != null && (
|
||||
<ErrorAlert error={organizationsError} />
|
||||
);
|
||||
|
||||
if (organization === undefined) {
|
||||
return (
|
||||
<>
|
||||
{refetchErrorAlert}
|
||||
<OrganizationAutocomplete
|
||||
value={null}
|
||||
options={organizations}
|
||||
required
|
||||
triggerClassName="w-60"
|
||||
optionsTabbable
|
||||
onChange={(next) => {
|
||||
if (next) {
|
||||
onOrganizationChange(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Alert severity="warning">
|
||||
This organization is unavailable, or you don't have access to it.
|
||||
</Alert>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{refetchErrorAlert}
|
||||
<SpendFilters
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onOrganizationChange={onOrganizationChange}
|
||||
menus={filterMenus}
|
||||
now={now}
|
||||
dateRange={dateRange}
|
||||
minDate={minDate}
|
||||
isReportLoading={reportQuery.isLoading}
|
||||
onDateRangeChange={onDateRangeChange}
|
||||
/>
|
||||
<SpendUsersTable reportQuery={reportQuery} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<SpendFiltersProps> = ({
|
||||
organizations,
|
||||
organization,
|
||||
onOrganizationChange,
|
||||
menus,
|
||||
now,
|
||||
dateRange,
|
||||
minDate,
|
||||
isReportLoading,
|
||||
onDateRangeChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{organizations.length > 1 && (
|
||||
<OrganizationAutocomplete
|
||||
value={organization}
|
||||
ariaLabel={`Organization ${getOrganizationLabel(
|
||||
organization,
|
||||
organizations,
|
||||
)}`}
|
||||
options={organizations}
|
||||
triggerClassName="basis-[150px] grow"
|
||||
optionsTabbable
|
||||
onChange={(next) => {
|
||||
if (next) {
|
||||
onOrganizationChange(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{menus && (
|
||||
<>
|
||||
<ProviderFilter menu={menus.provider} width={FILTER_WIDTH} />
|
||||
<ModelFilter menu={menus.model} width={FILTER_WIDTH} />
|
||||
<ClientFilter menu={menus.client} width={FILTER_WIDTH} />
|
||||
</>
|
||||
)}
|
||||
{isReportLoading ? (
|
||||
// The retention bound arrives with the report, so a picker shown
|
||||
// before it could offer days the server rejects.
|
||||
<Skeleton className="h-10 w-64" />
|
||||
) : dateRange ? (
|
||||
<DateRangePicker
|
||||
now={now}
|
||||
value={dateRange}
|
||||
onChange={onDateRangeChange}
|
||||
maxDays={MaxAISpendPeriodDays}
|
||||
minDate={minDate}
|
||||
size="lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-solid border-border px-3 py-2 text-sm text-content-secondary">
|
||||
<CalendarIcon className="size-4" />
|
||||
Current budget period
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user