mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
+8
-9
@@ -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>
|
||||
);
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user