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
This commit is contained in:
Ehab Younes
2026-07-22 12:17:22 +03:00
committed by GitHub
parent d485786dfe
commit 6ec45f75c6
16 changed files with 825 additions and 434 deletions
+90
View File
@@ -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 () => {
+92 -27
View File
@@ -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<TypesGen.GetInboxNotificationResponse> {
@@ -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<Item, Response>(
items: readonly Item[],
batchSize: number,
fetchBatch: (batch: readonly Item[]) => Promise<Response>,
): Promise<Response[]> {
const batches: Promise<Response>[] = [];
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<GroupWithAICostControl[]> => {
const response = await this.axios.get(
): Promise<TypesGen.Group[]> => {
const response = await this.axios.get<TypesGen.Group[]>(
`/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<TypesGen.OrganizationGroupsAISpend> => {
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<TypesGen.OrganizationGroupsAISpend>(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<TypesGen.GroupMembersAISpend> => {
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<TypesGen.GroupMembersAISpend>(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<GroupMembersResponseWithAICostControl> => {
): Promise<TypesGen.GroupMembersResponse> => {
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<TypesGen.GroupMembersResponse>(
url.toString(),
{ signal },
);
return response.data;
};
+41 -15
View File
@@ -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<GroupWithAICostControl[]>;
} satisfies UseQueryOptions<Group[]>;
};
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<OrganizationGroupsAISpend>;
};
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<GroupMembersAISpend>;
};
const getRootGroupQueryKey = (organization: string, groupName: string) => [
@@ -97,10 +130,7 @@ export function groupMembers(
organization: string,
groupName: string,
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<
GroupMembersResponseWithAICostControl,
UsersRequest
> {
): UsePaginatedQueryOptions<GroupMembersResponse, UsersRequest> {
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<Group[], unknown, GroupsByUserId>;
}
function selectGroupsByUserId(groups: Group[]): GroupsByUserId {
@@ -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<typeof GroupMemberBudgetCells> = {
export default meta;
type Story = StoryObj<typeof GroupMemberBudgetCells>;
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();
},
};
@@ -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" });
});
});
@@ -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 = <Badge size="sm">{badgeName(groupName)}</Badge>;
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 ? (
<Spinner loading size="sm" />
) : (
<Badge size="sm">{label}</Badge>
);
if (isResolvingGroupName) {
budgetGroup = <Spinner loading size="sm" />;
} else if (effectiveGroupName) {
budgetGroup = <Badge size="sm">{badgeName(effectiveGroupName)}</Badge>;
} else {
// The group can't be resolved (another org), so it can't be named.
budgetGroup = (
<LabelWithInfo label={EM_DASH} message={OTHER_ORG_MESSAGE} />
);
}
break;
}
}
let budget: ReactNode = EM_DASH;
if (costControl && fromOtherGroup) {
if (spend && fromOtherGroup) {
if (isResolvingGroupName) {
budget = <Spinner loading size="sm" />;
} else if (!effectiveGroupName) {
// The spend hides entirely when the governing group can't be resolved.
budget = (
<LabelWithInfo
label={EM_DASH}
message="This user's AI budget is managed by another org and isn't visible here."
/>
);
budget = <LabelWithInfo label={EM_DASH} message={OTHER_ORG_MESSAGE} />;
} else {
budget = (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1">
<span>
<span className="text-content-secondary">
{formatBudgetUSD(costControl.current_spend_micros)}
{formatBudgetUSD(spend.group_spend_micros)}
</span>{" "}
<span className="text-content-disabled">USD</span>
</span>
<InfoIconTooltip
<StatusIconTooltip
message={
<>
None of this user's spend counts against the{" "}
@@ -109,10 +110,10 @@ export const GroupMemberBudgetCells: FC<{
</div>
);
}
} 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 = (
<LabelWithInfo
label="Unlimited"
@@ -129,14 +130,13 @@ export const GroupMemberBudgetCells: FC<{
);
} else {
const limitLabel =
costControl.limit_source === "user_override" ? "Custom" : "Group";
spend.group_budget?.limit_source === "user_override"
? "Custom"
: "Group";
budget = (
<div className="flex flex-col gap-0.5">
<span>
<AIBudgetAmount
spend={costControl.current_spend_micros}
limit={limit}
/>{" "}
<AIBudgetAmount spend={spend.group_spend_micros} limit={limit} />{" "}
<span className="text-content-disabled">USD</span>
</span>
<span className="text-xs text-content-secondary">
@@ -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<Group, "id" | "organization_id">,
): 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 }> = ({
}) => (
<span className="inline-flex items-center gap-1">
{label}
<InfoIconTooltip message={message} />
<StatusIconTooltip message={message} />
</span>
);
+57 -14
View File
@@ -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<GroupMemberWithAICostControl | null>(null);
const [budgetUser, setBudgetUser] = useState<MemberWithSpend | null>(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 (
<div className="flex flex-col w-full gap-1 pb-8">
<div className="flex flex-row justify-between">
@@ -130,13 +167,20 @@ const GroupMembersPage: FC = () => {
<TableHead>
<div className="flex items-center gap-1">
AI budget
<InfoIconTooltip message={aiBudgetNote} />
{membersSpendQuery.isError ? (
<StatusIconTooltip
kind="warning"
message="AI spend couldn't be loaded, so budgets aren't shown."
/>
) : (
<StatusIconTooltip message={aiBudgetNote} />
)}
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1">
Budget group
<InfoIconTooltip message="The group or individual budget currently responsible for this user's AI spend. Admins can reassign this at any time, so spend history may span multiple sources." />
<StatusIconTooltip message="The group or individual budget currently responsible for this user's AI spend. Admins can reassign this at any time, so spend history may span multiple sources." />
</div>
</TableHead>
</>
@@ -153,7 +197,7 @@ const GroupMembersPage: FC = () => {
</TableCell>
</TableRow>
) : (
members.map((member) => (
membersWithSpend.map((member) => (
<GroupMemberRow
member={member}
group={groupData}
@@ -192,7 +236,7 @@ const GroupMembersPage: FC = () => {
}}
user={budgetUser}
currentGroup={groupData}
effectiveGroupId={budgetUser.ai_cost_control?.effective_group_id}
effectiveGroupId={budgetUser.spend?.effective_group_id}
/>
)}
</div>
@@ -292,7 +336,7 @@ const AddUsersDialog: FC<AddUsersDialogProps> = ({
};
interface GroupMemberRowProps {
member: GroupMemberWithAICostControl;
member: MemberWithSpend;
group: Group;
canUpdate: boolean;
showAIBudget: boolean;
@@ -308,9 +352,8 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
onManageAIBudget,
onRemove,
}) => {
const costControl = member.ai_cost_control;
const budgetFromOtherGroup =
effectiveBudgetGroup(costControl, group).kind === "other";
effectiveBudgetGroup(member.spend, group).kind === "other";
return (
<TableRow key={member.id}>
@@ -343,7 +386,7 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
<GroupMemberBudgetCells
group={group}
userID={member.id}
costControl={costControl}
spend={member.spend}
/>
)}
<TableCell className="w-1 whitespace-nowrap">
+216 -168
View File
@@ -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<GroupMemberAICostControl> = {},
): 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");
},
};
+2 -3
View File
@@ -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<typeof useQuery>;
+27 -3
View File
@@ -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 = () => {
</div>
<GroupsPageView
groups={groupsQuery.data}
groups={groupsWithSpend}
spendError={groupsSpendQuery.isError}
canCreateGroup={permissions.createGroup}
groupsEnabled={groupsEnabled}
showAIBudget={aibridgeVisible}
@@ -1,8 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import type { GroupAICostControl, GroupWithAICostControl } from "#/api/api";
import { MockGroup } from "#/testHelpers/entities";
import { GroupsPageView } from "./GroupsPageView";
import { GroupsPageView, type GroupWithSpend } from "./GroupsPageView";
const meta: Meta<typeof GroupsPageView> = {
title: "pages/OrganizationGroupsPage",
@@ -12,21 +11,21 @@ const meta: Meta<typeof GroupsPageView> = {
export default meta;
type Story = StoryObj<typeof GroupsPageView>;
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,
},
+44 -11
View File
@@ -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<GroupsPageViewProps> = ({
groups,
spendError,
canCreateGroup,
groupsEnabled,
showAIBudget,
@@ -62,7 +88,14 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
<TableHead className="w-2/5">
<div className="flex items-center gap-1">
AI budget
<InfoIconTooltip message="Current AI spend compared to the group's AI budget for the active period." />
{spendError ? (
<StatusIconTooltip
kind="warning"
message="AI spend couldn't be loaded, so budgets aren't shown."
/>
) : (
<StatusIconTooltip message="Current AI spend compared to the group's AI budget for the active period." />
)}
</div>
</TableHead>
)}
@@ -81,7 +114,7 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
};
interface GroupsTableBodyProps {
groups: GroupWithAICostControl[] | undefined;
groups: GroupWithSpend[] | undefined;
canCreateGroup: boolean;
showAIBudget: boolean;
}
@@ -130,7 +163,7 @@ const GroupsTableBody: FC<GroupsTableBodyProps> = ({
};
interface GroupRowProps {
group: GroupWithAICostControl;
group: GroupWithSpend;
showAIBudget: boolean;
}
@@ -176,19 +209,19 @@ const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
)}
</div>
) : (
"-"
EM_DASH
)}
</TableCell>
{showAIBudget && (
<TableCell>
{group.ai_cost_control ? (
{group.spend ? (
<AIBudgetUsage
currentSpend={group.ai_cost_control.current_spend_micros}
spendLimit={group.ai_cost_control.spend_limit_micros}
currentSpend={group.spend.current_spend_micros}
spendLimit={group.spend.spend_limit_micros}
/>
) : (
"-"
EM_DASH
)}
</TableCell>
)}
@@ -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" }) => (
<HelpPopover>
<HelpPopoverIconTrigger size="small" hoverEffect={false}>
<InfoIcon className={className} />
</HelpPopoverIconTrigger>
<HelpPopoverContent>
<HelpPopoverText>{message}</HelpPopoverText>
</HelpPopoverContent>
</HelpPopover>
);
@@ -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<typeof InfoIconTooltip> = {
title: "pages/OrganizationGroupsPage/InfoIconTooltip",
component: InfoIconTooltip,
const meta: Meta<typeof StatusIconTooltip> = {
title: "pages/OrganizationGroupsPage/StatusIconTooltip",
component: StatusIconTooltip,
args: { message: "Spend compared to the budget for the active period." },
};
export default meta;
type Story = StoryObj<typeof InfoIconTooltip>;
type Story = StoryObj<typeof StatusIconTooltip>;
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" },
};
@@ -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<StatusIconKind, ReactNode> = {
info: <InfoIcon className="text-content-secondary" />,
warning: <TriangleAlertIcon className="text-content-warning" />,
};
/** A popover tooltip anchored to a status icon, styled per `kind`. */
export const StatusIconTooltip: FC<{
message: ReactNode;
kind?: StatusIconKind;
}> = ({ message, kind = "info" }) => (
<HelpPopover>
<HelpPopoverIconTrigger size="small" hoverEffect={false}>
{statusIcon[kind]}
</HelpPopoverIconTrigger>
<HelpPopoverContent>
<HelpPopoverText>{message}</HelpPopoverText>
</HelpPopoverContent>
</HelpPopover>
);
+1 -9
View File
@@ -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", () => {