From 6ec45f75c6a961d6bd10199eca140a4696f42b2d Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Jul 2026 12:17:22 +0300 Subject: [PATCH] chore(site): replace handrolled AI spend types with generated types (#27342) The groups and group members pages consumed AI spend through local ai_cost_control wrapper types with TODOs to adopt the generated contract. Both spend endpoints are now live, so this switches to the generated types and hardens the fetch path. - Use OrganizationGroupsAISpend and GroupMembersAISpend from typesGenerated for the org groups and group members spend endpoints - Fetch identity and spend separately, joining by group_id/user_id at the container so each row gets one enriched object - Batch spend requests at the backend cap of 100 IDs in the API client and merge the responses, with table-driven tests covering both endpoints - Treat a null effective_group_id as a budget managed by a group in another org: render an em-dash with an info tooltip and skip the group name lookup instead of firing an empty-ID request Closes AIGOV-509 --- site/src/api/api.test.ts | 90 ++++ site/src/api/api.ts | 119 ++++-- site/src/api/queries/groups.ts | 56 ++- .../GroupMemberBudgetCells.stories.tsx | 103 ++--- .../GroupsPage/GroupMemberBudgetCells.test.ts | 28 +- .../GroupsPage/GroupMemberBudgetCells.tsx | 89 ++-- .../src/pages/GroupsPage/GroupMembersPage.tsx | 71 +++- .../pages/GroupsPage/GroupPage.stories.tsx | 384 ++++++++++-------- site/src/pages/GroupsPage/GroupPage.tsx | 5 +- site/src/pages/GroupsPage/GroupsPage.tsx | 30 +- .../GroupsPage/GroupsPageView.stories.tsx | 149 +++++-- site/src/pages/GroupsPage/GroupsPageView.tsx | 55 ++- site/src/pages/GroupsPage/InfoIconTooltip.tsx | 23 -- ...ries.tsx => StatusIconTooltip.stories.tsx} | 17 +- .../pages/GroupsPage/StatusIconTooltip.tsx | 30 ++ site/src/testHelpers/handlers.ts | 10 +- 16 files changed, 825 insertions(+), 434 deletions(-) delete mode 100644 site/src/pages/GroupsPage/InfoIconTooltip.tsx rename site/src/pages/GroupsPage/{InfoIconTooltip.stories.tsx => StatusIconTooltip.stories.tsx} (57%) create mode 100644 site/src/pages/GroupsPage/StatusIconTooltip.tsx diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 99eb768384..68051e7b5c 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -167,6 +167,96 @@ describe("api.ts", () => { }); }); + describe("AI spend requests", () => { + const window = { + period_start: "2026-07-01T00:00:00Z", + period_end: "2026-08-01T00:00:00Z", + }; + + // Each endpoint's request, URL path, and response for the given IDs. + const endpoints = [ + { + name: "getOrganizationGroupsAISpend", + path: "/api/v2/organizations/my-org/groups/ai/spend", + request: (ids: string[]) => + API.getOrganizationGroupsAISpend("my-org", ids), + response: (ids: string[]) => ({ + ...window, + groups: ids.map((id) => ({ + group_id: id, + spend_micros: 0, + budget: null, + })), + }), + }, + { + name: "getGroupMembersAISpend", + path: "/api/v2/groups/group-1/members/ai/spend", + request: (ids: string[]) => API.getGroupMembersAISpend("group-1", ids), + response: (ids: string[]) => ({ + ...window, + members: ids.map((id) => ({ + user_id: id, + effective_group_id: null, + group_budget: null, + group_spend_micros: 0, + })), + }), + }, + ]; + + afterEach(() => { + // The suite doesn't auto-restore mocks; don't leak the stubs. + vi.restoreAllMocks(); + }); + + describe.each(endpoints)("$name", ({ path, request, response }) => { + it("rejects an empty ID list without sending a request", async () => { + const getSpy = vi + .spyOn(axiosInstance, "get") + .mockResolvedValue({ data: {} }); + + await expect(request([])).rejects.toThrow(/must not be empty/); + expect(getSpy).not.toHaveBeenCalled(); + }); + + it("sends a single request for up to 100 IDs", async () => { + const ids = Array.from({ length: 25 }, (_, i) => `id-${i}`); + const getSpy = vi + .spyOn(axiosInstance, "get") + .mockResolvedValueOnce({ data: response(ids) }); + + const result = await request(ids); + + expect(getSpy).toHaveBeenCalledTimes(1); + expect(getSpy.mock.calls[0][0]).toContain(path); + expect(getSpy.mock.calls[0][0]).toContain( + encodeURIComponent(ids.join(",")), + ); + expect(result).toStrictEqual(response(ids)); + }); + + it("batches requests of 100 IDs and merges the results", async () => { + const ids = Array.from({ length: 150 }, (_, i) => `id-${i}`); + const getSpy = vi + .spyOn(axiosInstance, "get") + .mockResolvedValueOnce({ data: response(ids.slice(0, 100)) }) + .mockResolvedValueOnce({ data: response(ids.slice(100)) }); + + const result = await request(ids); + + expect(getSpy).toHaveBeenCalledTimes(2); + expect(getSpy.mock.calls[0][0]).toContain( + encodeURIComponent(ids.slice(0, 100).join(",")), + ); + expect(getSpy.mock.calls[1][0]).toContain( + encodeURIComponent(ids.slice(100).join(",")), + ); + expect(result).toStrictEqual(response(ids)); + }); + }); + }); + describe("update", () => { describe("given a running workspace", () => { it("stops with current version before starting with the latest version", async () => { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6432c0c61a..0930ba3bdd 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -200,29 +200,6 @@ type WatchInboxNotificationsParams = Readonly<{ read_status?: "read" | "unread" | "all"; }>; -// TODO(AIGOV-290): drop once `ai_cost_control` is generated onto Group. -export type GroupAICostControl = Readonly<{ - current_spend_micros: number; - spend_limit_micros: number | null; -}>; -export type GroupWithAICostControl = TypesGen.Group & - Readonly<{ ai_cost_control?: GroupAICostControl }>; - -// TODO(AIGOV-291): drop once `ai_cost_control` is generated onto ReducedUser. -export type GroupMemberAICostControl = Readonly<{ - current_spend_micros: number; - spend_limit_micros: number | null; - effective_group_id: string | null; - limit_source: TypesGen.AIBudgetLimitSource | null; -}>; -export type GroupMemberWithAICostControl = TypesGen.ReducedUser & - Readonly<{ ai_cost_control?: GroupMemberAICostControl }>; -export type GroupMembersResponseWithAICostControl = Omit< - TypesGen.GroupMembersResponse, - "users" -> & - Readonly<{ users: readonly GroupMemberWithAICostControl[] }>; - export function watchInboxNotifications( params?: WatchInboxNotificationsParams, ): OneWayWebSocket { @@ -430,6 +407,25 @@ export type DeploymentConfig = Readonly<{ options: TypesGen.SerpentOption[]; }>; +/** + * Fetches `items` in concurrent batches of at most `batchSize`, resolving + * with one response per batch, in input order. + */ +async function fetchInBatches( + items: readonly Item[], + batchSize: number, + fetchBatch: (batch: readonly Item[]) => Promise, +): Promise { + const batches: Promise[] = []; + for (let i = 0; i < items.length; i += batchSize) { + batches.push(fetchBatch(items.slice(i, i + batchSize))); + } + return Promise.all(batches); +} + +/** The AI spend endpoints reject requests with more than 100 IDs. */ +const aiSpendBatchSize = 100; + const aiProviderConfigsPath = "/api/v2/ai/providers"; const aiGatewayPath = "/api/v2/ai-gateway"; const chatModelConfigsPath = "/api/experimental/chats/model-configs"; @@ -2232,13 +2228,79 @@ class ApiMethods { */ getGroupsByOrganization = async ( organization: string, - ): Promise => { - const response = await this.axios.get( + ): Promise => { + const response = await this.axios.get( `/api/v2/organizations/${organization}/groups`, ); return response.data; }; + /** + * AI spend for the given groups in the active budget period. Fetched in + * batches of 100 (the backend cap) and merged. Requires at least one ID; + * the period window comes from the backend, so an empty request has no + * meaningful response. + * @param organization Can be the organization's ID or name + */ + getOrganizationGroupsAISpend = async ( + organization: string, + groupIds: readonly string[], + ): Promise => { + if (groupIds.length === 0) { + throw new Error("groupIds must not be empty"); + } + const responses = await fetchInBatches( + groupIds, + aiSpendBatchSize, + async (ids) => { + const url = getURLWithSearchParams( + `/api/v2/organizations/${organization}/groups/ai/spend`, + { group_ids: ids.join(",") }, + ); + const response = + await this.axios.get(url); + return response.data; + }, + ); + // Every batch reports the same active period window. + return { + ...responses[0], + groups: responses.flatMap((r) => r.groups), + }; + }; + + /** + * Per-member AI spend attributed to a group in the active budget period. + * Users not in the group, or whose spend the caller can't read, are + * omitted. Fetched in batches of 100 (the backend cap) and merged. + * Requires at least one ID. + */ + getGroupMembersAISpend = async ( + groupId: string, + userIds: readonly string[], + ): Promise => { + if (userIds.length === 0) { + throw new Error("userIds must not be empty"); + } + const responses = await fetchInBatches( + userIds, + aiSpendBatchSize, + async (ids) => { + const url = getURLWithSearchParams( + `/api/v2/groups/${groupId}/members/ai/spend`, + { user_ids: ids.join(",") }, + ); + const response = + await this.axios.get(url); + return response.data; + }, + ); + return { + ...responses[0], + members: responses.flatMap((r) => r.members), + }; + }; + /** * @param organization Can be the organization's ID or name */ @@ -2285,12 +2347,15 @@ class ApiMethods { groupName: string, filter?: UsersRequest, signal?: AbortSignal, - ): Promise => { + ): Promise => { const url = getURLWithSearchParams( `/api/v2/organizations/${organization}/groups/${groupName}/members`, filter, ); - const response = await this.axios.get(url.toString(), { signal }); + const response = await this.axios.get( + url.toString(), + { signal }, + ); return response.data; }; diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index d60766cefc..8093330fc2 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -1,15 +1,14 @@ import type { QueryClient, UseQueryOptions } from "react-query"; -import { - API, - type GroupMembersResponseWithAICostControl, - type GroupWithAICostControl, -} from "#/api/api"; +import { API } from "#/api/api"; import { isApiError } from "#/api/errors"; import type { CreateGroupRequest, Group, GroupAIBudget, + GroupMembersAISpend, + GroupMembersResponse, GroupRequest, + OrganizationGroupsAISpend, PatchGroupRequest, UsersRequest, } from "#/api/typesGenerated"; @@ -38,7 +37,41 @@ export const groupsByOrganization = (organization: string) => { return { queryKey: getGroupsByOrganizationQueryKey(organization), queryFn: () => API.getGroupsByOrganization(organization), - } satisfies UseQueryOptions; + } satisfies UseQueryOptions; +}; + +const getOrganizationGroupsAISpendQueryKey = ( + organization: string, + groupIds: readonly string[], +) => [ + ...getGroupsByOrganizationQueryKey(organization), + "aiSpend", + [...groupIds].sort(), +]; + +export const organizationGroupsAISpend = ( + organization: string, + groupIds: readonly string[], +) => { + return { + queryKey: getOrganizationGroupsAISpendQueryKey(organization, groupIds), + queryFn: () => API.getOrganizationGroupsAISpend(organization, groupIds), + } satisfies UseQueryOptions; +}; + +export const getGroupMembersAISpendQueryKey = ( + groupId: string, + userIds: readonly string[], +) => ["group", groupId, "members", "aiSpend", [...userIds].sort()]; + +export const groupMembersAISpend = ( + groupId: string, + userIds: readonly string[], +) => { + return { + queryKey: getGroupMembersAISpendQueryKey(groupId, userIds), + queryFn: () => API.getGroupMembersAISpend(groupId, userIds), + } satisfies UseQueryOptions; }; const getRootGroupQueryKey = (organization: string, groupName: string) => [ @@ -97,10 +130,7 @@ export function groupMembers( organization: string, groupName: string, searchParams: URLSearchParams, -): UsePaginatedQueryOptions< - GroupMembersResponseWithAICostControl, - UsersRequest -> { +): UsePaginatedQueryOptions { return { searchParams, queryPayload: ({ limit, offset }) => { @@ -131,11 +161,7 @@ export function groupsByUserIdInOrganization(organization: string) { return { ...groupsByOrganization(organization), select: selectGroupsByUserId, - } satisfies UseQueryOptions< - GroupWithAICostControl[], - unknown, - GroupsByUserId - >; + } satisfies UseQueryOptions; } function selectGroupsByUserId(groups: Group[]): GroupsByUserId { diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx b/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx index 8d8555cdb0..98ae0ceda1 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, spyOn, userEvent, within } from "storybook/test"; -import { API, type GroupMemberAICostControl } from "#/api/api"; +import { API } from "#/api/api"; import { getGroupByIdQueryKey } from "#/api/queries/groups"; +import type { GroupMemberAISpend } from "#/api/typesGenerated"; import { Table, TableBody, @@ -15,11 +16,11 @@ import { GroupMemberBudgetCells } from "./GroupMemberBudgetCells"; const group = MockGroupWithoutMembers; const testId = "member-ai-budget-member-1"; -const mockCostControl: GroupMemberAICostControl = { - current_spend_micros: 0, - spend_limit_micros: 7_000_000_000, +const mockSpend: GroupMemberAISpend = { + user_id: "member-1", effective_group_id: group.id, - limit_source: "group", + group_budget: { spend_limit_micros: 7_000_000_000, limit_source: "group" }, + group_spend_micros: 0, }; const openInfo = async (canvasElement: HTMLElement) => { @@ -57,8 +58,8 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const NoCostControl: Story = { - args: { costControl: undefined }, +export const NoSpendData: Story = { + args: { spend: undefined }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const cells = canvas.getAllByRole("cell"); @@ -71,9 +72,9 @@ export const NoCostControl: Story = { export const Unlimited: Story = { args: { - costControl: { - ...mockCostControl, - spend_limit_micros: null, + spend: { + ...mockSpend, + group_budget: null, effective_group_id: group.organization_id, }, }, @@ -90,16 +91,12 @@ export const Unlimited: Story = { }, }; -/** - * A null effective group means no budget applies: unlimited, no badge. - * TODO(AIGOV-509): null will instead mean a group in another org. - */ -export const NoGoverningGroup: Story = { +// Unlimited where the viewed group itself is the effective group. +export const UnlimitedThisGroup: Story = { args: { - costControl: { - ...mockCostControl, - spend_limit_micros: null, - effective_group_id: null, + spend: { + ...mockSpend, + group_budget: null, }, }, play: async ({ canvasElement }) => { @@ -107,15 +104,15 @@ export const NoGoverningGroup: Story = { await expect(await canvas.findByTestId(testId)).toHaveTextContent( "Unlimited", ); - await expect(canvas.getAllByRole("cell")[1]).toHaveTextContent("\u2014"); + await expect(canvas.getByText("Front-End")).toBeInTheDocument(); }, }; export const None: Story = { args: { - costControl: { - ...mockCostControl, - spend_limit_micros: 0, + spend: { + ...mockSpend, + group_budget: { spend_limit_micros: 0, limit_source: "group" }, effective_group_id: group.organization_id, }, }, @@ -131,7 +128,7 @@ export const None: Story = { export const Regular: Story = { args: { - costControl: { ...mockCostControl, current_spend_micros: 3_235_000_000 }, + spend: { ...mockSpend, group_spend_micros: 3_235_000_000 }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -144,11 +141,13 @@ export const Regular: Story = { export const Custom: Story = { args: { - costControl: { - ...mockCostControl, - current_spend_micros: 7_175_000_000, - spend_limit_micros: 9_000_000_000, - limit_source: "user_override", + spend: { + ...mockSpend, + group_spend_micros: 7_175_000_000, + group_budget: { + spend_limit_micros: 9_000_000_000, + limit_source: "user_override", + }, }, }, play: async ({ canvasElement }) => { @@ -162,25 +161,18 @@ export const Custom: Story = { }, }; -// Visual variants of Regular: the amount takes the warning/exceeded color. - -export const NearLimit: Story = { - args: { - costControl: { ...mockCostControl, current_spend_micros: 6_735_000_000 }, - }, -}; - +// Visual variant of Regular: the amount takes the exceeded color. export const OverLimit: Story = { args: { - costControl: { ...mockCostControl, current_spend_micros: 7_200_000_000 }, + spend: { ...mockSpend, group_spend_micros: 7_200_000_000 }, }, }; export const NotAttributed: Story = { args: { - costControl: { - ...mockCostControl, - current_spend_micros: 456_000_000, + spend: { + ...mockSpend, + group_spend_micros: 456_000_000, effective_group_id: MockGroup2.id, }, }, @@ -210,9 +202,9 @@ export const NotAttributed: Story = { /** Spinners while the group name resolves, not a flash of the fallback. */ export const ResolvingGroupName: Story = { args: { - costControl: { - ...mockCostControl, - current_spend_micros: 456_000_000, + spend: { + ...mockSpend, + group_spend_micros: 456_000_000, effective_group_id: MockGroup2.id, }, }, @@ -228,11 +220,12 @@ export const ResolvingGroupName: Story = { }, }; +/** An effective group that can't be resolved, standing in for another org's. */ export const NotAttributedUnknownGroup: Story = { args: { - costControl: { - ...mockCostControl, - current_spend_micros: 456_000_000, + spend: { + ...mockSpend, + group_spend_micros: 456_000_000, effective_group_id: "external-group", }, }, @@ -249,10 +242,22 @@ export const NotAttributedUnknownGroup: Story = { const cell = await canvas.findByTestId(testId); await expect(cell).toHaveTextContent("\u2014"); await expect(cell).not.toHaveTextContent("$456"); - await expect(canvas.getByText("Another org")).toBeInTheDocument(); + // The group cell shows an em-dash + info instead of naming the group. + const groupCell = canvas.getAllByRole("cell")[1]; + await expect(groupCell).toHaveTextContent("\u2014"); + await userEvent.click( + within(groupCell).getByRole("button", { name: "More info" }), + ); + await expect( + await within(document.body).findByText( + /managed by a group in another organization/, + ), + ).toBeInTheDocument(); + // Close this popover so the shared message only matches once. + await userEvent.keyboard("{Escape}"); const body = await openInfo(canvasElement); await expect( - await body.findByText(/managed by another org and isn't visible here/), + await body.findByText(/managed by a group in another organization/), ).toBeInTheDocument(); }, }; diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.test.ts b/site/src/pages/GroupsPage/GroupMemberBudgetCells.test.ts index 136bcfc0b2..2a93a22928 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.test.ts +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.test.ts @@ -1,31 +1,29 @@ import { describe, expect, it } from "vitest"; -import type { GroupMemberAICostControl } from "#/api/api"; +import type { GroupMemberAISpend } from "#/api/typesGenerated"; import { effectiveBudgetGroup } from "./GroupMemberBudgetCells"; const group = { id: "group-1", organization_id: "org-1" }; -const mockCostControl: GroupMemberAICostControl = { - current_spend_micros: 0, - spend_limit_micros: null, +const mockSpend: GroupMemberAISpend = { + user_id: "user-1", effective_group_id: null, - limit_source: "group", + group_budget: null, + group_spend_micros: 0, }; describe("effectiveBudgetGroup", () => { - it("is none without cost control data", () => { + it("is none without spend data", () => { expect(effectiveBudgetGroup(undefined, group)).toEqual({ kind: "none" }); }); - it("is none without a governing group", () => { - expect(effectiveBudgetGroup(mockCostControl, group)).toEqual({ - kind: "none", - }); + it("is other without a governing group (budget in another org)", () => { + expect(effectiveBudgetGroup(mockSpend, group)).toEqual({ kind: "other" }); }); it("is everyone for the org-wide Everyone group", () => { expect( effectiveBudgetGroup( - { ...mockCostControl, effective_group_id: "org-1" }, + { ...mockSpend, effective_group_id: "org-1" }, group, ), ).toEqual({ kind: "everyone" }); @@ -34,7 +32,7 @@ describe("effectiveBudgetGroup", () => { it("is everyone when the viewed group is Everyone itself", () => { expect( effectiveBudgetGroup( - { ...mockCostControl, effective_group_id: "org-1" }, + { ...mockSpend, effective_group_id: "org-1" }, { id: "org-1", organization_id: "org-1" }, ), ).toEqual({ kind: "everyone" }); @@ -43,7 +41,7 @@ describe("effectiveBudgetGroup", () => { it("is this for the given group", () => { expect( effectiveBudgetGroup( - { ...mockCostControl, effective_group_id: "group-1" }, + { ...mockSpend, effective_group_id: "group-1" }, group, ), ).toEqual({ kind: "this" }); @@ -52,9 +50,9 @@ describe("effectiveBudgetGroup", () => { it("is other for any other group", () => { expect( effectiveBudgetGroup( - { ...mockCostControl, effective_group_id: "group-2" }, + { ...mockSpend, effective_group_id: "group-2" }, group, ), - ).toEqual({ kind: "other", groupId: "group-2" }); + ).toEqual({ kind: "other" }); }); }); diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx b/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx index 82ac9511f1..9efe469042 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx @@ -1,17 +1,20 @@ import type { FC, ReactNode } from "react"; import { useQuery } from "react-query"; -import type { GroupMemberAICostControl } from "#/api/api"; import { groupById } from "#/api/queries/groups"; -import type { Group } from "#/api/typesGenerated"; +import type { Group, GroupMemberAISpend } from "#/api/typesGenerated"; import { AIBudgetAmount } from "#/components/AIBudgetAmount/AIBudgetAmount"; import { Badge } from "#/components/Badge/Badge"; import { Spinner } from "#/components/Spinner/Spinner"; import { TableCell } from "#/components/Table/Table"; import { formatBudgetUSD } from "#/utils/currency"; -import { InfoIconTooltip } from "./InfoIconTooltip"; +import { StatusIconTooltip } from "./StatusIconTooltip"; const EM_DASH = "\u2014"; +/** Shown on both cells when the governing group is in another org. */ +const OTHER_ORG_MESSAGE = + "This user's AI budget is managed by a group in another organization and isn't visible here."; + /** * The AI budget and Budget group cells for a group member. Spend only counts * against the viewed group; another group's budget shows as unattributed. @@ -19,23 +22,25 @@ const EM_DASH = "\u2014"; export const GroupMemberBudgetCells: FC<{ group: Group; userID: string; - costControl: GroupMemberAICostControl | undefined; -}> = ({ group, userID, costControl }) => { - const effective = effectiveBudgetGroup(costControl, group); + spend: GroupMemberAISpend | undefined; +}> = ({ group, userID, spend }) => { + const effective = effectiveBudgetGroup(spend, group); const fromOtherGroup = effective.kind === "other"; + // A null effective_group_id is a group in another org that can't be + // fetched, so only resolve the name when an ID exists. const { data: effectiveGroup, isLoading: isResolvingGroupName } = useQuery({ - ...groupById(fromOtherGroup ? effective.groupId : "", { + ...groupById(spend?.effective_group_id ?? "", { exclude_members: true, }), - enabled: fromOtherGroup, + enabled: fromOtherGroup && Boolean(spend?.effective_group_id), }); const effectiveGroupName = effectiveGroup?.display_name || effectiveGroup?.name; const groupName = group.display_name || group.name; // A user override shows as "(individual)" on the governing group's badge. const badgeName = (name: string) => - costControl?.limit_source === "user_override" + spend?.group_budget?.limit_source === "user_override" ? `${name} (individual)` : name; @@ -51,43 +56,39 @@ export const GroupMemberBudgetCells: FC<{ budgetGroup = {badgeName(groupName)}; break; case "other": { - // "Another org" when the governing group can't be resolved. - const label = effectiveGroupName - ? badgeName(effectiveGroupName) - : "Another org"; // Wait for the name to resolve rather than flashing the fallback. - budgetGroup = isResolvingGroupName ? ( - - ) : ( - {label} - ); + if (isResolvingGroupName) { + budgetGroup = ; + } else if (effectiveGroupName) { + budgetGroup = {badgeName(effectiveGroupName)}; + } else { + // The group can't be resolved (another org), so it can't be named. + budgetGroup = ( + + ); + } break; } } let budget: ReactNode = EM_DASH; - if (costControl && fromOtherGroup) { + if (spend && fromOtherGroup) { if (isResolvingGroupName) { budget = ; } else if (!effectiveGroupName) { // The spend hides entirely when the governing group can't be resolved. - budget = ( - - ); + budget = ; } else { budget = (
- {formatBudgetUSD(costControl.current_spend_micros)} + {formatBudgetUSD(spend.group_spend_micros)} {" "} USD - None of this user's spend counts against the{" "} @@ -109,10 +110,10 @@ export const GroupMemberBudgetCells: FC<{
); } - } else if (costControl) { - const limit = costControl.spend_limit_micros; + } else if (spend) { + const limit = spend.group_budget?.spend_limit_micros ?? null; if (limit === null) { - // Also covers a missing governing group: no budget applies. + // The effective group has no budget, so no limit applies. budget = ( - {" "} + {" "} USD @@ -165,22 +165,21 @@ type EffectiveBudgetGroup = | { kind: "none" } | { kind: "everyone" } | { kind: "this" } - | { kind: "other"; groupId: string }; + | { kind: "other" }; /** * Resolves which group governs a member's AI budget. "none" means no budget - * applies; "everyone" is the org-wide fallback when no named group sets a - * budget. - * - * TODO(AIGOV-509): null will instead mean a group in another org. + * data loaded; "everyone" is the org-wide fallback when no named group sets a + * budget. A null effective group means the budget resolves to a group in + * another organization, so it can't be shown here. */ export function effectiveBudgetGroup( - costControl: GroupMemberAICostControl | undefined, + spend: GroupMemberAISpend | undefined, group: Pick, ): EffectiveBudgetGroup { - const groupId = costControl?.effective_group_id ?? null; + const groupId = spend?.effective_group_id ?? null; if (groupId === null) { - return { kind: "none" }; + return spend === undefined ? { kind: "none" } : { kind: "other" }; } // Everyone shares the org's id; checked first so it wins when the viewed // group is Everyone itself. @@ -190,7 +189,7 @@ export function effectiveBudgetGroup( if (groupId === group.id) { return { kind: "this" }; } - return { kind: "other", groupId }; + return { kind: "other" }; } const LabelWithInfo: FC<{ label: ReactNode; message: ReactNode }> = ({ @@ -199,6 +198,6 @@ const LabelWithInfo: FC<{ label: ReactNode; message: ReactNode }> = ({ }) => ( {label} - + ); diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index de3b481445..beda74199d 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -1,16 +1,22 @@ import dayjs from "dayjs"; import { EllipsisVerticalIcon, UserPlusIcon } from "lucide-react"; -import { type FC, useState } from "react"; +import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useOutletContext } from "react-router"; import { toast } from "sonner"; -import type { GroupMemberWithAICostControl } from "#/api/api"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { addMembers, groupAIBudget, removeMember } from "#/api/queries/groups"; +import { + addMembers, + groupAIBudget, + groupMembersAISpend, + removeMember, +} from "#/api/queries/groups"; import { meAISpend } from "#/api/queries/users"; import type { Group, + GroupMemberAISpend, OrganizationMemberWithUserData, + ReducedUser, } from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; @@ -51,9 +57,13 @@ import { GroupMemberBudgetCells, } from "./GroupMemberBudgetCells"; import type { GroupPageOutletContext } from "./GroupPage"; -import { InfoIconTooltip } from "./InfoIconTooltip"; +import { StatusIconTooltip } from "./StatusIconTooltip"; import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog"; +type MemberWithSpend = ReducedUser & { + readonly spend: GroupMemberAISpend | undefined; +}; + const GroupMembersPage: FC = () => { const { group: groupData, @@ -69,8 +79,7 @@ const GroupMembersPage: FC = () => { removeMember(queryClient, organization), ); const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; - const [budgetUser, setBudgetUser] = - useState(null); + const [budgetUser, setBudgetUser] = useState(null); const { experiments } = useDashboard(); // TODO(AIGOV-443): drop the experiment gate once cost control is stable. @@ -85,6 +94,23 @@ const GroupMembersPage: FC = () => { ...groupAIBudget(groupData.id), enabled: aibridgeVisible, }); + const memberIds = members.map((member) => member.id); + const membersSpendQuery = useQuery({ + ...groupMembersAISpend(groupData.id, memberIds), + enabled: aibridgeVisible && memberIds.length > 0, + }); + const spendByUserId = new Map( + membersSpendQuery.data?.members.map((spend) => [spend.user_id, spend]) ?? + [], + ); + // Join each member with its spend (undefined when loading, failed, or + // omitted by the backend) so each row gets a single object. + const membersWithSpend = members.map( + (member): MemberWithSpend => ({ + ...member, + spend: spendByUserId.get(member.id), + }), + ); const aiBudgetNote = [ "Monthly AI spend for this user.", // Spend resets at period_end, rendered in the viewer's local time. @@ -97,6 +123,17 @@ const GroupMembersPage: FC = () => { .filter(Boolean) .join(" "); + useEffect(() => { + if (membersSpendQuery.error) { + toast.error( + getErrorMessage(membersSpendQuery.error, "Unable to load AI spend."), + { + description: getErrorDetail(membersSpendQuery.error), + }, + ); + } + }, [membersSpendQuery.error]); + return (
@@ -130,13 +167,20 @@ const GroupMembersPage: FC = () => {
AI budget - + {membersSpendQuery.isError ? ( + + ) : ( + + )}
Budget group - +
@@ -153,7 +197,7 @@ const GroupMembersPage: FC = () => { ) : ( - members.map((member) => ( + membersWithSpend.map((member) => ( { }} user={budgetUser} currentGroup={groupData} - effectiveGroupId={budgetUser.ai_cost_control?.effective_group_id} + effectiveGroupId={budgetUser.spend?.effective_group_id} /> )}
@@ -292,7 +336,7 @@ const AddUsersDialog: FC = ({ }; interface GroupMemberRowProps { - member: GroupMemberWithAICostControl; + member: MemberWithSpend; group: Group; canUpdate: boolean; showAIBudget: boolean; @@ -308,9 +352,8 @@ const GroupMemberRow: FC = ({ onManageAIBudget, onRemove, }) => { - const costControl = member.ai_cost_control; const budgetFromOtherGroup = - effectiveBudgetGroup(costControl, group).kind === "other"; + effectiveBudgetGroup(member.spend, group).kind === "other"; return ( @@ -343,7 +386,7 @@ const GroupMemberRow: FC = ({ )} diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index a852331bf7..8b8c15ba3b 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -4,13 +4,10 @@ import { reactRouterOutlet, reactRouterParameters, } from "storybook-addon-remix-react-router"; -import { - API, - type GroupMemberAICostControl, - type GroupMemberWithAICostControl, -} from "#/api/api"; +import { API } from "#/api/api"; import { getGroupByIdQueryKey, + getGroupMembersAISpendQueryKey, getGroupMembersQueryKey, getGroupQueryKey, getGroupsForUserQueryKey, @@ -24,6 +21,8 @@ import { } from "#/api/queries/users"; import type { GroupAIBudget, + GroupMemberAISpend, + GroupMembersAISpend, ReducedUser, UserAISpendStatus, } from "#/api/typesGenerated"; @@ -149,6 +148,33 @@ export const GroupMembersError: Story = { }, }; +/** The members list loads but the spend fetch fails: budget cells fall back to an em dash. */ +export const MembersSpendError: Story = { + beforeEach: () => { + spyOn(API, "getGroupMembersAISpend").mockRejectedValue( + new Error("test members spend error"), + ); + }, + parameters: { + features: ["aibridge"], + experiments: ["ai-gateway-cost-control"], + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ users: [MockUserMember], count: 1 }), + permissionsQuery({ canUpdateGroup: true }), + { key: meAISpendKey, data: mockUserAISpend }, + { key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("table", { name: "Group members" }); + await expect( + await canvas.findByTestId(`member-ai-budget-${MockUserMember.id}`), + ).toHaveTextContent("\u2014"); + }, +}; + export const LoadingPermissions: Story = { parameters: { queries: [ @@ -257,19 +283,23 @@ export const FiltersByMembers: Story = { }, }; -const mockCostControl: GroupMemberAICostControl = { - current_spend_micros: 1_345_000_000, - spend_limit_micros: 9_000_000_000, +const mockSpend: GroupMemberAISpend = { + user_id: "", effective_group_id: MockGroupWithoutMembers.id, - limit_source: "group", + group_budget: { spend_limit_micros: 9_000_000_000, limit_source: "group" }, + group_spend_micros: 1_345_000_000, }; -const memberWithSpend = ( - user: ReducedUser, - overrides: Partial = {}, -): GroupMemberWithAICostControl => ({ - ...user, - ai_cost_control: { ...mockCostControl, ...overrides }, +const membersSpendQuery = (spends: readonly GroupMemberAISpend[]) => ({ + key: getGroupMembersAISpendQueryKey( + MockGroupWithoutMembers.id, + spends.map((spend) => spend.user_id), + ), + data: { + period_start: "2026-06-01T00:00:00Z", + period_end: "2026-07-01T00:00:00Z", + members: spends, + } satisfies GroupMembersAISpend, }); const mockGroupBudget: GroupAIBudget = { @@ -286,14 +316,20 @@ export const WithMemberAIBudget: Story = { queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ - users: [ - memberWithSpend(MockUserMember, { - current_spend_micros: 3_235_000_000, - spend_limit_micros: 7_000_000_000, - }), - ], + users: [MockUserMember], count: 1, }), + membersSpendQuery([ + { + ...mockSpend, + user_id: MockUserMember.id, + group_spend_micros: 3_235_000_000, + group_budget: { + spend_limit_micros: 7_000_000_000, + limit_source: "group", + }, + }, + ]), permissionsQuery({ canUpdateGroup: true }), { key: meAISpendKey, data: mockUserAISpend }, { @@ -363,13 +399,16 @@ export const AIBudgetActionDisabledForOtherGroup: Story = { queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ - users: [ - memberWithSpend(MockUserOwner, { - effective_group_id: MockGroup2.id, - }), - ], + users: [MockUserOwner], count: 1, }), + membersSpendQuery([ + { + ...mockSpend, + user_id: MockUserOwner.id, + effective_group_id: MockGroup2.id, + }, + ]), permissionsQuery({ canUpdateGroup: true }), { key: meAISpendKey, data: mockUserAISpend }, { key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null }, @@ -394,6 +433,8 @@ export const AIBudgetActionDisabledForOtherGroup: Story = { ).toBeInTheDocument(); await userEvent.keyboard("{Escape}"); + // The menu stays enabled while the governing group's name resolves. + await canvas.findByText("developer"); await userEvent.click( canvas.getAllByRole("button", { name: "Open menu" })[0], ); @@ -404,22 +445,25 @@ export const AIBudgetActionDisabledForOtherGroup: Story = { }, }; -/** A null effective group means no budget applies: the member is unlimited. */ -export const WithMemberAIBudgetWithoutEffectiveGroup: Story = { +/** The other-org effective group can't be resolved, so the action disables. */ +export const WithMemberAIBudgetInAnotherOrg: Story = { parameters: { features: ["aibridge"], experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ - users: [ - memberWithSpend(MockUserOwner, { - effective_group_id: null, - spend_limit_micros: null, - }), - ], + users: [MockUserOwner], count: 1, }), + membersSpendQuery([ + { + ...mockSpend, + user_id: MockUserOwner.id, + effective_group_id: null, + group_budget: null, + }, + ]), permissionsQuery({ canUpdateGroup: true }), { key: meAISpendKey, data: mockUserAISpend }, { key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null }, @@ -440,24 +484,22 @@ export const WithMemberAIBudgetWithoutEffectiveGroup: Story = { const cell = await canvas.findByTestId( `member-ai-budget-${MockUserOwner.id}`, ); - await expect(cell).toHaveTextContent("Unlimited"); + await expect(cell).toHaveTextContent("\u2014"); await userEvent.click( within(cell).getByRole("button", { name: "More info" }), ); - await expect(await body.findByText(/isn't restricted/)).toBeInTheDocument(); + await expect( + await body.findByText(/managed by a group in another organization/), + ).toBeInTheDocument(); await userEvent.keyboard("{Escape}"); await userEvent.click( canvas.getAllByRole("button", { name: "Open menu" })[0], ); - await userEvent.click( - await body.findByRole("menuitem", { name: "Manage AI budget" }), - ); - await userEvent.click(await body.findByText("Override group budget")); - await expect( - await body.findByText("Custom monthly budget"), - ).toBeInTheDocument(); - await expect(body.queryByText(/\(default\)/)).not.toBeInTheDocument(); + const menuItem = await body.findByRole("menuitem", { + name: "Manage AI budget", + }); + await expect(menuItem).toHaveAttribute("aria-disabled", "true"); }, }; @@ -468,9 +510,10 @@ export const OpenAIBudgetForCurrentGroupMember: Story = { queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ - users: [memberWithSpend(MockUserOwner)], + users: [MockUserOwner], count: 1, }), + membersSpendQuery([{ ...mockSpend, user_id: MockUserOwner.id }]), permissionsQuery({ canUpdateGroup: true }), { key: meAISpendKey, data: mockUserAISpend }, { key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null }, @@ -506,6 +549,115 @@ const unresolvedGroupId = "external-org-group"; /** Per-state details are covered by GroupMemberBudgetCells.stories. */ +const showcaseMembers: ReducedUser[] = [ + { + ...MockUserMember, + id: "member-none", + username: "alice", + name: "Alice Chen", + }, + { + ...MockUserMember, + id: "member-unlimited", + username: "bob", + name: "Bob Diaz", + }, + { + ...MockUserMember, + id: "member-elsewhere", + username: "priya", + name: "Priya Nair", + }, + { + ...MockUserMember, + id: "member-regular", + username: "jordan", + name: "Jordan Lee", + }, + { + ...MockUserMember, + id: "member-custom", + username: "sam", + name: "Sam Okafor", + status: "dormant", + }, + { + ...MockUserMember, + id: "member-near", + username: "morgan", + name: "Morgan Ito", + }, + { + ...MockUserMember, + id: "member-over", + username: "casey", + name: "Casey Novak", + }, + { + ...MockUserMember, + id: "member-other-group", + username: "riley", + name: "Riley Park", + status: "suspended", + }, +]; + +const showcaseSpends: GroupMemberAISpend[] = [ + { + ...mockSpend, + user_id: "member-none", + group_budget: { spend_limit_micros: 0, limit_source: "group" }, + group_spend_micros: 0, + effective_group_id: MockGroupWithoutMembers.organization_id, + }, + { + ...mockSpend, + user_id: "member-unlimited", + group_budget: null, + group_spend_micros: 0, + effective_group_id: MockGroupWithoutMembers.organization_id, + }, + { + ...mockSpend, + user_id: "member-elsewhere", + group_spend_micros: 456_000_000, + effective_group_id: unresolvedGroupId, + }, + { + ...mockSpend, + user_id: "member-regular", + group_budget: { spend_limit_micros: 7_000_000_000, limit_source: "group" }, + group_spend_micros: 3_235_000_000, + }, + { + ...mockSpend, + user_id: "member-custom", + group_budget: { + spend_limit_micros: 9_000_000_000, + limit_source: "user_override", + }, + group_spend_micros: 7_175_000_000, + }, + { + ...mockSpend, + user_id: "member-near", + group_budget: { spend_limit_micros: 7_000_000_000, limit_source: "group" }, + group_spend_micros: 6_735_000_000, + }, + { + ...mockSpend, + user_id: "member-over", + group_budget: { spend_limit_micros: 7_000_000_000, limit_source: "group" }, + group_spend_micros: 7_200_000_000, + }, + { + ...mockSpend, + user_id: "member-other-group", + group_spend_micros: 456_000_000, + effective_group_id: MockGroup2.id, + }, +]; + export const AIBudgetShowcase: Story = { parameters: { features: ["aibridge"], @@ -513,102 +665,10 @@ export const AIBudgetShowcase: Story = { queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ - users: [ - { - ...MockUserMember, - id: "member-none", - username: "alice", - name: "Alice Chen", - ai_cost_control: { - ...mockCostControl, - current_spend_micros: 0, - spend_limit_micros: 0, - effective_group_id: MockGroupWithoutMembers.organization_id, - }, - }, - { - ...MockUserMember, - id: "member-unlimited", - username: "bob", - name: "Bob Diaz", - ai_cost_control: { - ...mockCostControl, - current_spend_micros: 0, - spend_limit_micros: null, - effective_group_id: MockGroupWithoutMembers.organization_id, - }, - }, - { - ...MockUserMember, - id: "member-elsewhere", - username: "priya", - name: "Priya Nair", - ai_cost_control: { - ...mockCostControl, - current_spend_micros: 456_000_000, - effective_group_id: unresolvedGroupId, - }, - }, - { - ...MockUserMember, - id: "member-regular", - username: "jordan", - name: "Jordan Lee", - ai_cost_control: { - ...mockCostControl, - current_spend_micros: 3_235_000_000, - spend_limit_micros: 7_000_000_000, - }, - }, - { - ...MockUserMember, - id: "member-custom", - username: "sam", - name: "Sam Okafor", - status: "dormant", - ai_cost_control: { - ...mockCostControl, - current_spend_micros: 7_175_000_000, - limit_source: "user_override", - }, - }, - { - ...MockUserMember, - id: "member-near", - username: "morgan", - name: "Morgan Ito", - ai_cost_control: { - ...mockCostControl, - current_spend_micros: 6_735_000_000, - spend_limit_micros: 7_000_000_000, - }, - }, - { - ...MockUserMember, - id: "member-over", - username: "casey", - name: "Casey Novak", - ai_cost_control: { - ...mockCostControl, - current_spend_micros: 7_200_000_000, - spend_limit_micros: 7_000_000_000, - }, - }, - { - ...MockUserMember, - id: "member-other-group", - username: "riley", - name: "Riley Park", - status: "suspended", - ai_cost_control: { - ...mockCostControl, - current_spend_micros: 456_000_000, - effective_group_id: MockGroup2.id, - }, - }, - ], + users: showcaseMembers, count: 8, }), + membersSpendQuery(showcaseSpends), permissionsQuery({ canUpdateGroup: true }), { key: meAISpendKey, data: mockUserAISpend }, { @@ -629,37 +689,14 @@ export const AIBudgetShowcase: Story = { const canvas = within(canvasElement); await canvas.findByRole("table", { name: "Group members" }); - await expect( - await canvas.findByTestId("member-ai-budget-member-none"), - ).toHaveTextContent("None"); - await expect( - await canvas.findByTestId("member-ai-budget-member-unlimited"), - ).toHaveTextContent("Unlimited"); - await expect( - await canvas.findByTestId("member-ai-budget-member-regular"), - ).toHaveTextContent("$3,235 USD"); - await expect( - await canvas.findByTestId("member-ai-budget-member-custom"), - ).toHaveTextContent("$7,175 USD"); - await expect( - await canvas.findByTestId("member-ai-budget-member-other-group"), - ).toHaveTextContent("Not attributed to this group"); - - const elsewhereCell = await canvas.findByTestId( - "member-ai-budget-member-elsewhere", - ); - await expect(elsewhereCell).not.toHaveTextContent("$456"); + // Every member renders their own joined spend cell. + for (const member of showcaseMembers) { + await expect( + await canvas.findByTestId(`member-ai-budget-${member.id}`), + ).toBeInTheDocument(); + } const body = within(document.body); - await userEvent.click( - within( - await canvas.findByTestId("member-ai-budget-member-none"), - ).getByRole("button", { name: "More info" }), - ); - await expect( - await body.findByText(/no AI spending allowance/), - ).toBeInTheDocument(); - await userEvent.keyboard("{Escape}"); // Everyone (unset) must not disable the override action. await userEvent.click( @@ -669,5 +706,16 @@ export const AIBudgetShowcase: Story = { name: "Manage AI budget", }); await expect(manageItem).not.toHaveAttribute("aria-disabled", "true"); + await userEvent.keyboard("{Escape}"); + + // Another named group does disable it. + const otherGroupMenu = await canvas.findAllByRole("button", { + name: "Open menu", + }); + await userEvent.click(otherGroupMenu[7]); + const disabledItem = await body.findByRole("menuitem", { + name: "Manage AI budget", + }); + await expect(disabledItem).toHaveAttribute("aria-disabled", "true"); }, }; diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index 2bcaa687e5..5778fbfc6e 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -9,7 +9,6 @@ import { useSearchParams, } from "react-router"; import { toast } from "sonner"; -import type { GroupMemberWithAICostControl } from "#/api/api"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { deleteGroup, @@ -17,7 +16,7 @@ import { groupMembers, groupPermissions, } from "#/api/queries/groups"; -import type { Group } from "#/api/typesGenerated"; +import type { Group, ReducedUser } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; @@ -39,7 +38,7 @@ import { AIBudgetPeriod } from "./AIBudgetPeriod"; export type GroupPageOutletContext = { group: Group; - members: readonly GroupMemberWithAICostControl[]; + members: readonly ReducedUser[]; permissions: { canUpdateGroup: boolean }; organization: string; groupQuery: ReturnType; diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 13e6425343..65d592a9ef 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -4,7 +4,10 @@ import { useQuery } from "react-query"; import { Link as RouterLink } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { groupsByOrganization } from "#/api/queries/groups"; +import { + groupsByOrganization, + organizationGroupsAISpend, +} from "#/api/queries/groups"; import { organizationsPermissions } from "#/api/queries/organizations"; import { Button } from "#/components/Button/Button"; import { EmptyState } from "#/components/EmptyState/EmptyState"; @@ -19,7 +22,7 @@ import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; import { useGroupsSettings } from "./GroupsPageProvider"; -import { GroupsPageView } from "./GroupsPageView"; +import { GroupsPageView, joinGroupsSpend } from "./GroupsPageView"; const GroupsPage: FC = () => { const { template_rbac: groupsEnabled, aibridge } = useFeatureVisibility(); @@ -33,6 +36,15 @@ const GroupsPage: FC = () => { ...groupsByOrganization(organization?.name ?? ""), enabled: Boolean(organization), }); + const groupIds = groupsQuery.data?.map((group) => group.id) ?? []; + const groupsSpendQuery = useQuery({ + ...organizationGroupsAISpend(organization?.name ?? "", groupIds), + enabled: aibridgeVisible && Boolean(organization) && groupIds.length > 0, + }); + const groupsWithSpend = joinGroupsSpend( + groupsQuery.data, + groupsSpendQuery.data, + ); const permissionsQuery = useQuery({ ...organizationsPermissions([organization?.id ?? ""]), enabled: Boolean(organization), @@ -49,6 +61,17 @@ const GroupsPage: FC = () => { } }, [groupsQuery.error]); + useEffect(() => { + if (groupsSpendQuery.error) { + toast.error( + getErrorMessage(groupsSpendQuery.error, "Unable to load AI spend."), + { + description: getErrorDetail(groupsSpendQuery.error), + }, + ); + } + }, [groupsSpendQuery.error]); + useEffect(() => { if (permissionsQuery.error) { toast.error( @@ -105,7 +128,8 @@ const GroupsPage: FC = () => {
= { title: "pages/OrganizationGroupsPage", @@ -12,21 +11,21 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const aiGroup = ( - id: string, - name: string, - ai_cost_control?: GroupAICostControl, -): GroupWithAICostControl => ({ +const mockGroupWithSpend: GroupWithSpend = { ...MockGroup, + spend: undefined, +}; + +const aiGroup = (id: string, name: string): GroupWithSpend => ({ + ...mockGroupWithSpend, id, name, display_name: name, - ai_cost_control, }); export const NotEnabled: Story = { args: { - groups: [MockGroup], + groups: [{ ...mockGroupWithSpend }], canCreateGroup: true, groupsEnabled: false, }, @@ -34,7 +33,7 @@ export const NotEnabled: Story = { export const WithGroups: Story = { args: { - groups: [MockGroup], + groups: [{ ...mockGroupWithSpend }], canCreateGroup: true, groupsEnabled: true, }, @@ -46,35 +45,63 @@ export const WithAIBudgets: Story = { groupsEnabled: true, showAIBudget: true, groups: [ - aiGroup("ai-unlimited", "Unlimited", { - current_spend_micros: 25_492_000_000, - spend_limit_micros: null, - }), - aiGroup("ai-under", "Under budget", { - current_spend_micros: 10_000_000, - spend_limit_micros: 50_000_000, - }), - aiGroup("ai-warning", "Near limit", { - current_spend_micros: 46_000_000, - spend_limit_micros: 50_000_000, - }), - aiGroup("ai-at-limit", "At limit", { - current_spend_micros: 50_000_000, - spend_limit_micros: 50_000_000, - }), - aiGroup("ai-over", "Over budget", { - current_spend_micros: 75_000_000, - spend_limit_micros: 50_000_000, - }), - aiGroup("ai-zero-budget", "Zero budget", { - current_spend_micros: 5_000_000, - spend_limit_micros: 0, - }), - aiGroup("ai-zero-both", "Zero spend and budget", { - current_spend_micros: 0, - spend_limit_micros: 0, - }), - // No cost control exercises the missing-spend "-" fallback. + { + ...aiGroup("ai-unlimited", "Unlimited"), + spend: { + group_id: "ai-unlimited", + current_spend_micros: 25_492_000_000, + spend_limit_micros: null, + }, + }, + { + ...aiGroup("ai-under", "Under budget"), + spend: { + group_id: "ai-under", + current_spend_micros: 10_000_000, + spend_limit_micros: 50_000_000, + }, + }, + { + ...aiGroup("ai-warning", "Near limit"), + spend: { + group_id: "ai-warning", + current_spend_micros: 46_000_000, + spend_limit_micros: 50_000_000, + }, + }, + { + ...aiGroup("ai-at-limit", "At limit"), + spend: { + group_id: "ai-at-limit", + current_spend_micros: 50_000_000, + spend_limit_micros: 50_000_000, + }, + }, + { + ...aiGroup("ai-over", "Over budget"), + spend: { + group_id: "ai-over", + current_spend_micros: 75_000_000, + spend_limit_micros: 50_000_000, + }, + }, + { + ...aiGroup("ai-zero-budget", "Zero budget"), + spend: { + group_id: "ai-zero-budget", + current_spend_micros: 5_000_000, + spend_limit_micros: 0, + }, + }, + { + ...aiGroup("ai-zero-both", "Zero spend and budget"), + spend: { + group_id: "ai-zero-both", + current_spend_micros: 0, + spend_limit_micros: 0, + }, + }, + // No spend exercises the missing-spend em-dash fallback. aiGroup("ai-no-data", "No data"), ], }, @@ -97,7 +124,7 @@ export const WithAIBudgets: Story = { ).toHaveTextContent("$5 / $0 USD"); await expect( await canvas.findByTestId("group-ai-no-data"), - ).toHaveTextContent("-"); + ).toHaveTextContent("\u2014"); }, }; @@ -111,7 +138,43 @@ export const WithAIBudgetsLoading: Story = { }, }; -// Cost control unset for a group: the cell falls back to "-". +// Spend still loading: every AI budget cell falls back to an em dash. +export const WithAIBudgetsSpendLoading: Story = { + args: { + groups: [aiGroup("ai-loading", "Spend loading")], + canCreateGroup: true, + groupsEnabled: true, + showAIBudget: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByTestId("group-ai-loading"), + ).toHaveTextContent("\u2014"); + }, +}; + +// The spend fetch failed: the column header shows a warning and cells an em dash. +export const WithAIBudgetsSpendError: Story = { + args: { + groups: [aiGroup("ai-errored", "Spend errored")], + spendError: true, + canCreateGroup: true, + groupsEnabled: true, + showAIBudget: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByTestId("group-ai-errored"), + ).toHaveTextContent("\u2014"); + await expect( + canvas.getByRole("button", { name: "More info" }), + ).toBeInTheDocument(); + }, +}; + +// Cost control unset for a group: the cell falls back to an em dash. export const WithAIBudgetsSpendUnavailable: Story = { args: { groups: [aiGroup("ai-unavailable", "Spend unavailable")], @@ -123,7 +186,7 @@ export const WithAIBudgetsSpendUnavailable: Story = { const canvas = within(canvasElement); await expect( await canvas.findByTestId("group-ai-unavailable"), - ).toHaveTextContent("-"); + ).toHaveTextContent("\u2014"); }, }; @@ -143,7 +206,7 @@ export const WithoutAIBudgetColumn: Story = { export const WithDisplayGroup: Story = { args: { - groups: [{ ...MockGroup, name: "front-end" }], + groups: [{ ...mockGroupWithSpend, name: "front-end" }], canCreateGroup: true, groupsEnabled: true, }, diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 0c160d42b3..2668f25417 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -1,7 +1,7 @@ import { ChevronRightIcon, PlusIcon } from "lucide-react"; import type { FC } from "react"; import { Link as RouterLink, useNavigate } from "react-router"; -import type { GroupWithAICostControl } from "#/api/api"; +import type { Group, OrganizationGroupsAISpend } from "#/api/typesGenerated"; import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; @@ -25,10 +25,35 @@ import { } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; import { docs } from "#/utils/docs"; -import { InfoIconTooltip } from "./InfoIconTooltip"; +import { StatusIconTooltip } from "./StatusIconTooltip"; + +const EM_DASH = "\u2014"; + +export type GroupWithSpend = Group & { + readonly spend: OrganizationGroupsAISpend["groups"][number] | undefined; +}; + +/** Attach each group's spend, when present, so rows get a single object. */ +export const joinGroupsSpend = ( + groups: Group[] | undefined, + groupsSpend: OrganizationGroupsAISpend | undefined, +): GroupWithSpend[] | undefined => { + if (groups === undefined) { + return undefined; + } + const spendByGroupId = new Map( + groupsSpend?.groups.map((spend) => [spend.group_id, spend]) ?? [], + ); + return groups.map((group) => ({ + ...group, + spend: spendByGroupId.get(group.id), + })); +}; type GroupsPageViewProps = { - groups: GroupWithAICostControl[] | undefined; + groups: GroupWithSpend[] | undefined; + /** True when the spend query failed; cells then show an em dash. */ + spendError: boolean; canCreateGroup: boolean; groupsEnabled: boolean; showAIBudget: boolean; @@ -36,6 +61,7 @@ type GroupsPageViewProps = { export const GroupsPageView: FC = ({ groups, + spendError, canCreateGroup, groupsEnabled, showAIBudget, @@ -62,7 +88,14 @@ export const GroupsPageView: FC = ({
AI budget - + {spendError ? ( + + ) : ( + + )}
)} @@ -81,7 +114,7 @@ export const GroupsPageView: FC = ({ }; interface GroupsTableBodyProps { - groups: GroupWithAICostControl[] | undefined; + groups: GroupWithSpend[] | undefined; canCreateGroup: boolean; showAIBudget: boolean; } @@ -130,7 +163,7 @@ const GroupsTableBody: FC = ({ }; interface GroupRowProps { - group: GroupWithAICostControl; + group: GroupWithSpend; showAIBudget: boolean; } @@ -176,19 +209,19 @@ const GroupRow: FC = ({ group, showAIBudget }) => { )} ) : ( - "-" + EM_DASH )} {showAIBudget && ( - {group.ai_cost_control ? ( + {group.spend ? ( ) : ( - "-" + EM_DASH )} )} diff --git a/site/src/pages/GroupsPage/InfoIconTooltip.tsx b/site/src/pages/GroupsPage/InfoIconTooltip.tsx deleted file mode 100644 index a9d074fb40..0000000000 --- a/site/src/pages/GroupsPage/InfoIconTooltip.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { InfoIcon } from "lucide-react"; -import type { FC, ReactNode } from "react"; -import { - HelpPopover, - HelpPopoverContent, - HelpPopoverIconTrigger, - HelpPopoverText, -} from "#/components/HelpPopover/HelpPopover"; - -/** An (i) info tooltip. `className` sets the icon color. */ -export const InfoIconTooltip: FC<{ - message: ReactNode; - className?: string; -}> = ({ message, className = "text-content-secondary" }) => ( - - - - - - {message} - - -); diff --git a/site/src/pages/GroupsPage/InfoIconTooltip.stories.tsx b/site/src/pages/GroupsPage/StatusIconTooltip.stories.tsx similarity index 57% rename from site/src/pages/GroupsPage/InfoIconTooltip.stories.tsx rename to site/src/pages/GroupsPage/StatusIconTooltip.stories.tsx index 70d563083b..503a9e0c9e 100644 --- a/site/src/pages/GroupsPage/InfoIconTooltip.stories.tsx +++ b/site/src/pages/GroupsPage/StatusIconTooltip.stories.tsx @@ -1,17 +1,17 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { InfoIconTooltip } from "./InfoIconTooltip"; +import { StatusIconTooltip } from "./StatusIconTooltip"; -const meta: Meta = { - title: "pages/OrganizationGroupsPage/InfoIconTooltip", - component: InfoIconTooltip, +const meta: Meta = { + title: "pages/OrganizationGroupsPage/StatusIconTooltip", + component: StatusIconTooltip, args: { message: "Spend compared to the budget for the active period." }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; -export const Default: Story = { +export const Info: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button", { name: "More info" })); @@ -23,7 +23,6 @@ export const Default: Story = { }, }; -// Muted icon, used where it sits next to greyed content. -export const Muted: Story = { - args: { className: "text-content-disabled" }, +export const Warning: Story = { + args: { kind: "warning" }, }; diff --git a/site/src/pages/GroupsPage/StatusIconTooltip.tsx b/site/src/pages/GroupsPage/StatusIconTooltip.tsx new file mode 100644 index 0000000000..4613aead3e --- /dev/null +++ b/site/src/pages/GroupsPage/StatusIconTooltip.tsx @@ -0,0 +1,30 @@ +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { + HelpPopover, + HelpPopoverContent, + HelpPopoverIconTrigger, + HelpPopoverText, +} from "#/components/HelpPopover/HelpPopover"; + +type StatusIconKind = "info" | "warning"; + +const statusIcon: Record = { + info: , + warning: , +}; + +/** A popover tooltip anchored to a status icon, styled per `kind`. */ +export const StatusIconTooltip: FC<{ + message: ReactNode; + kind?: StatusIconKind; +}> = ({ message, kind = "info" }) => ( + + + {statusIcon[kind]} + + + {message} + + +); diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 246fe68aca..c1c6c057f1 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -332,15 +332,7 @@ export const handlers = [ // Groups http.get("/api/v2/organizations/:organizationId/groups", () => { - return HttpResponse.json([ - { - ...MockGroup, - ai_cost_control: { - current_spend_micros: 25_492_000_000, - spend_limit_micros: null, - }, - }, - ]); + return HttpResponse.json([M.MockGroup]); }), http.post("/api/v2/organizations/:organizationId/groups", () => {