diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 363a83c3bd..4a498abcca 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -200,13 +200,28 @@ type WatchInboxNotificationsParams = Readonly<{ read_status?: "read" | "unread" | "all"; }>; -// TODO(AIGOV-290): replace with the generated type from typesGenerated.ts once -// the GET /organizations/{org}/groups/ai/spend endpoint exists in the backend. -export type OrganizationGroupAISpend = Readonly<{ - group_id: string; +// 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: "group" | "override" | 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, @@ -2210,25 +2225,13 @@ class ApiMethods { */ getGroupsByOrganization = async ( organization: string, - ): Promise => { + ): Promise => { const response = await this.axios.get( `/api/v2/organizations/${organization}/groups`, ); return response.data; }; - /** - * @param organization Can be the organization's ID or name - */ - getOrganizationGroupsAISpend = async ( - organization: string, - ): Promise => { - const response = await this.axios.get( - `/api/v2/organizations/${organization}/groups/ai/spend`, - ); - return response.data; - }; - /** * @param organization Can be the organization's ID or name */ @@ -2243,6 +2246,16 @@ class ApiMethods { return response.data; }; + getGroupById = async ( + groupId: string, + req: TypesGen.GroupRequest, + signal?: AbortSignal, + ): Promise => { + const url = getURLWithSearchParams(`/api/v2/groups/${groupId}`, req); + const response = await this.axios.get(url, { signal }); + return response.data; + }; + /** * @param organization Can be the organization's ID or name */ @@ -2265,7 +2278,7 @@ class ApiMethods { groupName: string, filter?: UsersRequest, signal?: AbortSignal, - ): Promise => { + ): Promise => { const url = getURLWithSearchParams( `/api/v2/organizations/${organization}/groups/${groupName}/members`, filter, diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 63787023ad..d60766cefc 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -1,11 +1,14 @@ import type { QueryClient, UseQueryOptions } from "react-query"; -import { API, type OrganizationGroupAISpend } from "#/api/api"; +import { + API, + type GroupMembersResponseWithAICostControl, + type GroupWithAICostControl, +} from "#/api/api"; import { isApiError } from "#/api/errors"; import type { CreateGroupRequest, Group, GroupAIBudget, - GroupMembersResponse, GroupRequest, PatchGroupRequest, UsersRequest, @@ -35,19 +38,7 @@ export const groupsByOrganization = (organization: string) => { return { queryKey: getGroupsByOrganizationQueryKey(organization), queryFn: () => API.getGroupsByOrganization(organization), - } satisfies UseQueryOptions; -}; - -const getOrganizationGroupsAISpendQueryKey = (organization: string) => [ - ...getGroupsByOrganizationQueryKey(organization), - "aiSpend", -]; - -export const organizationGroupsAISpend = (organization: string) => { - return { - queryKey: getOrganizationGroupsAISpendQueryKey(organization), - queryFn: () => API.getOrganizationGroupsAISpend(organization), - } satisfies UseQueryOptions; + } satisfies UseQueryOptions; }; const getRootGroupQueryKey = (organization: string, groupName: string) => [ @@ -57,6 +48,22 @@ const getRootGroupQueryKey = (organization: string, groupName: string) => [ groupName, ]; +export const getGroupByIdQueryKey = (groupId: string, req: GroupRequest) => [ + "group", + groupId, + req, +]; + +export const groupById = ( + groupId: string, + req: GroupRequest, +): UseQueryOptions => { + return { + queryKey: getGroupByIdQueryKey(groupId, req), + queryFn: ({ signal }) => API.getGroupById(groupId, req, signal), + }; +}; + export const getGroupQueryKey = ( organization: string, groupName: string, @@ -90,7 +97,10 @@ export function groupMembers( organization: string, groupName: string, searchParams: URLSearchParams, -): UsePaginatedQueryOptions { +): UsePaginatedQueryOptions< + GroupMembersResponseWithAICostControl, + UsersRequest +> { return { searchParams, queryPayload: ({ limit, offset }) => { @@ -121,7 +131,11 @@ export function groupsByUserIdInOrganization(organization: string) { return { ...groupsByOrganization(organization), select: selectGroupsByUserId, - } satisfies UseQueryOptions; + } satisfies UseQueryOptions< + GroupWithAICostControl[], + unknown, + GroupsByUserId + >; } function selectGroupsByUserId(groups: Group[]): GroupsByUserId { diff --git a/site/src/pages/GroupsPage/AIBudgetUsage.stories.tsx b/site/src/pages/GroupsPage/AIBudgetUsage.stories.tsx new file mode 100644 index 0000000000..bc1ce77cb5 --- /dev/null +++ b/site/src/pages/GroupsPage/AIBudgetUsage.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { AIBudgetUsage } from "./AIBudgetUsage"; + +// Spend and limit are in micros (1_000_000 = $1). +const meta: Meta = { + title: "pages/OrganizationGroupsPage/AIBudgetUsage", + component: AIBudgetUsage, +}; + +export default meta; +type Story = StoryObj; + +// No limit: spend shown against "unlimited". +export const Unlimited: Story = { + args: { currentSpend: 25_492_000_000, spendLimit: null }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent("$25,492 / unlimited USD"); + }, +}; + +// Well under budget: spend rendered in the normal (secondary) color. +export const UnderBudget: Story = { + args: { currentSpend: 10_000_000, spendLimit: 50_000_000 }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent("$10 / $50 USD"); + }, +}; + +// >=85% of budget: spend rendered in the warning color. +export const NearLimit: Story = { + args: { currentSpend: 46_000_000, spendLimit: 50_000_000 }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent("$46 / $50 USD"); + }, +}; + +// Over budget: spend rendered in the destructive color. +export const OverBudget: Story = { + args: { currentSpend: 75_000_000, spendLimit: 50_000_000 }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent("$75 / $50 USD"); + }, +}; + +// Zero budget with spend: treated as exceeded. +export const ZeroBudget: Story = { + args: { currentSpend: 5_000_000, spendLimit: 0 }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent("$5 / $0 USD"); + }, +}; diff --git a/site/src/pages/GroupsPage/AIBudgetUsage.tsx b/site/src/pages/GroupsPage/AIBudgetUsage.tsx new file mode 100644 index 0000000000..4e3c2f27a9 --- /dev/null +++ b/site/src/pages/GroupsPage/AIBudgetUsage.tsx @@ -0,0 +1,31 @@ +import type { FC } from "react"; +import { getSeverity, severityTextClassName } from "#/utils/budget"; +import { formatBudgetUSD } from "#/utils/currency"; + +/** Spend against budget. Highlights spend once it nears or exceeds the limit; values in micros. */ +export const AIBudgetUsage: FC<{ + currentSpend: number; + spendLimit: number | null; +}> = ({ currentSpend, spendLimit }) => { + if (spendLimit === null) { + return ( + + {formatBudgetUSD(currentSpend)}{" "} + / unlimited USD + + ); + } + + const severity = getSeverity(currentSpend, spendLimit); + return ( + + + {formatBudgetUSD(currentSpend)} + {" "} + + / {formatBudgetUSD(spendLimit)} + {" "} + USD + + ); +}; diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index be2487e38a..57557c61b6 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -1,14 +1,17 @@ import { EllipsisVerticalIcon, UserPlusIcon } from "lucide-react"; -import { type FC, useState } from "react"; -import { useMutation, useQueryClient } from "react-query"; +import { type FC, type ReactNode, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { useOutletContext } from "react-router"; import { toast } from "sonner"; +import type { + GroupMemberAICostControl, + GroupMemberWithAICostControl, +} from "#/api/api"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { addMembers, removeMember } from "#/api/queries/groups"; +import { addMembers, groupById, removeMember } from "#/api/queries/groups"; import type { Group, OrganizationMemberWithUserData, - ReducedUser, } from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; @@ -43,7 +46,10 @@ import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { isEveryoneGroup } from "#/modules/groups"; import { cn } from "#/utils/cn"; +import { formatBudgetUSD } from "#/utils/currency"; +import { AIBudgetUsage } from "./AIBudgetUsage"; import type { GroupPageOutletContext } from "./GroupPage"; +import { InfoIconTooltip } from "./InfoIconTooltip"; import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog"; const GroupMembersPage: FC = () => { @@ -61,7 +67,8 @@ const GroupMembersPage: FC = () => { removeMember(queryClient, organization), ); const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; - const [budgetUser, setBudgetUser] = useState(null); + const [budgetUser, setBudgetUser] = + useState(null); const { experiments } = useDashboard(); // TODO(AIGOV-443): remove the ai-gateway-cost-control experiment gate once @@ -92,8 +99,28 @@ const GroupMembersPage: FC = () => { - User - Status + + User + + + Status + + {aibridgeVisible && ( + <> + +
+ AI budget + +
+
+ +
+ Budget type + +
+
+ + )}
@@ -112,7 +139,7 @@ const GroupMembersPage: FC = () => { group={groupData} key={member.id} canUpdate={canUpdateGroup} - aiBudgetVisible={aibridgeVisible} + showAIBudget={aibridgeVisible} onManageAIBudget={() => setBudgetUser(member)} onRemove={async () => { const mutation = removeMemberMutation.mutateAsync({ @@ -144,9 +171,8 @@ const GroupMembersPage: FC = () => { } }} user={budgetUser} - // TODO(#26401): pass the member's effective group, not the page's - // group, once the effective-group API exists. currentGroup={groupData} + effectiveGroupId={budgetUser.ai_cost_control?.effective_group_id} /> )} @@ -246,10 +272,10 @@ const AddUsersDialog: FC = ({ }; interface GroupMemberRowProps { - member: ReducedUser; + member: GroupMemberWithAICostControl; group: Group; canUpdate: boolean; - aiBudgetVisible: boolean; + showAIBudget: boolean; onManageAIBudget: () => void; onRemove: () => void; } @@ -258,13 +284,13 @@ const GroupMemberRow: FC = ({ member, group, canUpdate, - aiBudgetVisible, + showAIBudget, onManageAIBudget, onRemove, }) => { return ( - + = ({ /> = ({
{member.status}
- + {showAIBudget && ( + + )} + {canUpdate && ( @@ -299,7 +332,7 @@ const GroupMemberRow: FC = ({ - {aiBudgetVisible && ( + {showAIBudget && ( AI Budget @@ -319,4 +352,74 @@ const GroupMemberRow: FC = ({ ); }; +const GroupMemberAIBudgetCells: FC<{ + group: Group; + userID: string; + costControl: GroupMemberAICostControl | undefined; +}> = ({ group, userID, costControl }) => { + // Limit and type apply only when this group is the member's effective source. + const onEffectiveGroup = costControl?.effective_group_id === group.id; + + let budget: ReactNode = "-"; + let type: ReactNode = "-"; + if (costControl) { + // Another group sets this member's budget; surface their spend only. + budget = onEffectiveGroup ? ( + + ) : ( + + {formatBudgetUSD(costControl.current_spend_micros)} + + + ); + if (onEffectiveGroup && costControl.limit_source) { + type = budgetTypeLabels[costControl.limit_source]; + } + } + + return ( + <> + + {budget} + + {type} + + ); +}; + +// Names the group whose budget governs a member, resolving the id to a name. +const MemberBudgetSourceTooltip: FC<{ groupId: string | null }> = ({ + groupId, +}) => { + const { data: group } = useQuery({ + ...groupById(groupId ?? "", { exclude_members: true }), + enabled: Boolean(groupId), + }); + const name = group?.display_name || group?.name; + return ( + + ); +}; + +const budgetTypeLabels: Record< + NonNullable, + string +> = { + group: "Group", + override: "Individual", +}; + export default GroupMembersPage; diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 7bbd955a8d..c700f661cb 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -4,8 +4,13 @@ import { reactRouterOutlet, reactRouterParameters, } from "storybook-addon-remix-react-router"; -import { API } from "#/api/api"; import { + API, + type GroupMemberAICostControl, + type GroupMemberWithAICostControl, +} from "#/api/api"; +import { + getGroupByIdQueryKey, getGroupMembersQueryKey, getGroupQueryKey, getGroupsForUserQueryKey, @@ -14,13 +19,15 @@ import { } from "#/api/queries/groups"; import { organizationMembersKey } from "#/api/queries/organizations"; import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users"; -import type { UserAIBudgetOverride } from "#/api/typesGenerated"; +import type { ReducedUser, UserAIBudgetOverride } from "#/api/typesGenerated"; import { MockDefaultOrganization, MockGroup, + MockGroup2, MockGroupWithoutMembers, MockOrganizationMember, MockOrganizationMember2, + MockUserMember, MockUserOwner, } from "#/testHelpers/entities"; import { withDashboardProvider } from "#/testHelpers/storybook"; @@ -234,12 +241,159 @@ export const FiltersByMembers: Story = { const mockOwnerOverride: UserAIBudgetOverride = { user_id: MockUserOwner.id, - group_id: MockGroup.id, + group_id: MockGroup2.id, spend_limit_micros: 12_000_000_000, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; +// Member row with inline AI cost control; defaults to the page's group. +const memberWithSpend = ( + user: ReducedUser, + overrides: Partial = {}, +): GroupMemberWithAICostControl => ({ + ...user, + ai_cost_control: { + current_spend_micros: 1_345_000_000, + spend_limit_micros: 9_000_000_000, + effective_group_id: MockGroupWithoutMembers.id, + limit_source: "override", + ...overrides, + }, +}); + +const memberWithoutSpend: GroupMemberWithAICostControl = { + ...MockUserMember, + id: "no-spend-user", + username: "no-spend", +}; + +export const WithMemberAIBudget: Story = { + parameters: { + features: ["aibridge"], + experiments: ["ai-gateway-cost-control"], + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ + users: [ + // Override source, no limit. + memberWithSpend(MockUserOwner, { spend_limit_micros: null }), + // Group source, finite limit. + memberWithSpend(MockUserMember, { + current_spend_micros: 5_492_000_000, + spend_limit_micros: 7_000_000_000, + limit_source: "group", + }), + // No cost control exercises the missing-spend "-" fallback. + memberWithoutSpend, + ], + count: 3, + }), + permissionsQuery({ canUpdateGroup: true }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("AI budget")).toBeInTheDocument(); + await expect(await canvas.findByText("Budget type")).toBeInTheDocument(); + // Override source, no limit. + await expect( + await canvas.findByTestId(`member-ai-budget-${MockUserOwner.id}`), + ).toHaveTextContent("$1,345 / unlimited USD"); + await expect(await canvas.findByText("Individual")).toBeInTheDocument(); + // Group source, finite limit. + await expect( + await canvas.findByTestId(`member-ai-budget-${MockUserMember.id}`), + ).toHaveTextContent("$5,492 / $7,000 USD"); + await expect(await canvas.findByText("Group")).toBeInTheDocument(); + // No spend reported for this member. + await expect( + await canvas.findByTestId(`member-ai-budget-${memberWithoutSpend.id}`), + ).toHaveTextContent("-"); + + // Column header tooltips. + const body = within(document.body); + await userEvent.click( + within(canvas.getByText("AI budget")).getByRole("button", { + name: "More info", + }), + ); + await expect( + await body.findByText( + "A member's AI spend against their budget for the current period.", + ), + ).toBeInTheDocument(); + await userEvent.click( + within(canvas.getByText("Budget type")).getByRole("button", { + name: "More info", + }), + ); + await expect( + await body.findByText( + "Whether a member's budget comes from their group or an individual override.", + ), + ).toBeInTheDocument(); + }, +}; + +// Budget governed by another group (effective_group_id points elsewhere): only +// the member's spend shows, with no limit or type. +export const WithMemberAIBudgetFromAnotherGroup: Story = { + parameters: { + features: ["aibridge"], + experiments: ["ai-gateway-cost-control"], + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ + users: [ + memberWithSpend(MockUserOwner, { + effective_group_id: MockGroup2.id, + limit_source: "group", + }), + ], + count: 1, + }), + permissionsQuery({ canUpdateGroup: true }), + { + key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }), + data: MockGroup2, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const cell = await canvas.findByTestId( + `member-ai-budget-${MockUserOwner.id}`, + ); + await expect(cell).toHaveTextContent("$1,345"); + await expect(cell).not.toHaveTextContent("USD"); + await expect(canvas.queryByText("Group")).not.toBeInTheDocument(); + // The info tooltip names the group that sets the budget. + await userEvent.click( + within(cell).getByRole("button", { name: "More info" }), + ); + await expect(await body.findByText(/developer/)).toBeInTheDocument(); + }, +}; + +// AI Bridge hidden: neither the AI budget nor the budget type column renders. +export const WithoutMemberAIBudgetColumn: Story = { + parameters: { + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ users: [MockUserOwner], count: 1 }), + permissionsQuery({ canUpdateGroup: true }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("table", { name: "Group members" }); + expect(canvas.queryByText("AI budget")).not.toBeInTheDocument(); + expect(canvas.queryByText("Budget type")).not.toBeInTheDocument(); + }, +}; + export const OpenAIBudgetFromMemberMenu: Story = { parameters: { features: ["aibridge"], @@ -247,10 +401,19 @@ export const OpenAIBudgetFromMemberMenu: Story = { queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ - users: MockGroup.members, - count: MockGroup.members.length, + users: [ + memberWithSpend(MockUserOwner, { + effective_group_id: MockGroup2.id, + }), + MockUserMember, + ], + count: 2, }), permissionsQuery({ canUpdateGroup: true }), + { + key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }), + data: MockGroup2, + }, { key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: mockOwnerOverride, @@ -263,7 +426,7 @@ export const OpenAIBudgetFromMemberMenu: Story = { data: [MockGroup], }, { - key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, + key: groupAIBudget(MockGroup2.id).queryKey, data: null, }, ], @@ -281,5 +444,111 @@ export const OpenAIBudgetFromMemberMenu: Story = { await expect( await body.findByText("Custom monthly budget"), ).toBeInTheDocument(); + await expect(await body.findByText("developer")).toBeInTheDocument(); + }, +}; + +// effective_group_id null: spend greys out, dialog marks no "(default)". +export const WithMemberAIBudgetWithoutEffectiveGroup: Story = { + parameters: { + features: ["aibridge"], + experiments: ["ai-gateway-cost-control"], + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ + users: [ + memberWithSpend(MockUserOwner, { + effective_group_id: null, + limit_source: "group", + }), + ], + count: 1, + }), + permissionsQuery({ canUpdateGroup: true }), + { key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null }, + { + key: getGroupsForUserQueryKey( + MockUserOwner.id, + MockGroupWithoutMembers.organization_id, + ), + data: [MockGroup2], + }, + { key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + const cell = await canvas.findByTestId( + `member-ai-budget-${MockUserOwner.id}`, + ); + await expect(cell).toHaveTextContent("$1,345"); + await expect(cell).not.toHaveTextContent("USD"); + // Generic fallback when no group name resolves. + await userEvent.click( + within(cell).getByRole("button", { name: "More info" }), + ); + await expect( + await body.findByText(/set by another group/), + ).toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + + await userEvent.click( + canvas.getAllByRole("button", { name: "Open menu" })[0], + ); + await userEvent.click( + await body.findByRole("menuitem", { name: "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(); + }, +}; + +// Governed by the viewed group: the dialog marks it "(default)". +export const OpenAIBudgetForCurrentGroupMember: Story = { + parameters: { + features: ["aibridge"], + experiments: ["ai-gateway-cost-control"], + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ + users: [ + memberWithSpend(MockUserOwner, { + effective_group_id: MockGroupWithoutMembers.id, + limit_source: "group", + }), + ], + count: 1, + }), + permissionsQuery({ canUpdateGroup: true }), + { key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null }, + { + key: getGroupsForUserQueryKey( + MockUserOwner.id, + MockGroupWithoutMembers.organization_id, + ), + data: [MockGroup2], + }, + { key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click( + canvas.getAllByRole("button", { name: "Open menu" })[0], + ); + await userEvent.click( + await body.findByRole("menuitem", { name: "AI Budget" }), + ); + await userEvent.click(await body.findByText("Override group budget")); + await expect( + await body.findByText("Front-End (default)"), + ).toBeInTheDocument(); }, }; diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index a0520e9844..6867c3d63f 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -9,6 +9,7 @@ import { useSearchParams, } from "react-router"; import { toast } from "sonner"; +import type { GroupMemberWithAICostControl } from "#/api/api"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { deleteGroup, @@ -16,7 +17,7 @@ import { groupMembers, groupPermissions, } from "#/api/queries/groups"; -import type { Group, ReducedUser } from "#/api/typesGenerated"; +import type { Group } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; @@ -37,7 +38,7 @@ import { pageTitle } from "#/utils/page"; export type GroupPageOutletContext = { group: Group; - members: readonly ReducedUser[]; + members: readonly GroupMemberWithAICostControl[]; permissions: { canUpdateGroup: boolean }; organization: string; groupQuery: ReturnType; diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 6b99dd79bb..13e6425343 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -4,10 +4,7 @@ import { useQuery } from "react-query"; import { Link as RouterLink } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { - groupsByOrganization, - organizationGroupsAISpend, -} from "#/api/queries/groups"; +import { groupsByOrganization } from "#/api/queries/groups"; import { organizationsPermissions } from "#/api/queries/organizations"; import { Button } from "#/components/Button/Button"; import { EmptyState } from "#/components/EmptyState/EmptyState"; @@ -40,10 +37,6 @@ const GroupsPage: FC = () => { ...organizationsPermissions([organization?.id ?? ""]), enabled: Boolean(organization), }); - const aiSpendQuery = useQuery({ - ...organizationGroupsAISpend(organization?.name ?? ""), - enabled: Boolean(organization) && groupsEnabled && aibridgeVisible, - }); useEffect(() => { if (groupsQuery.error) { @@ -67,17 +60,6 @@ const GroupsPage: FC = () => { } }, [permissionsQuery.error]); - useEffect(() => { - if (aiSpendQuery.error) { - toast.error( - getErrorMessage(aiSpendQuery.error, "Unable to load AI budget."), - { - description: getErrorDetail(aiSpendQuery.error), - }, - ); - } - }, [aiSpendQuery.error]); - if (!organization) { return ; } @@ -126,11 +108,7 @@ const GroupsPage: FC = () => { groups={groupsQuery.data} canCreateGroup={permissions.createGroup} groupsEnabled={groupsEnabled} - aiBudget={ - aibridgeVisible - ? { spend: aiSpendQuery.data, isLoading: aiSpendQuery.isLoading } - : undefined - } + showAIBudget={aibridgeVisible} /> ); diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index b0ab89a9f9..c04d6d48f5 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; -import type { Group } from "#/api/typesGenerated"; +import type { GroupAICostControl, GroupWithAICostControl } from "#/api/api"; import { MockGroup } from "#/testHelpers/entities"; import { GroupsPageView } from "./GroupsPageView"; @@ -12,11 +12,16 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const aiGroup = (id: string, name: string): Group => ({ +const aiGroup = ( + id: string, + name: string, + ai_cost_control?: GroupAICostControl, +): GroupWithAICostControl => ({ ...MockGroup, id, name, display_name: name, + ai_cost_control, }); export const NotEnabled: Story = { @@ -39,57 +44,39 @@ export const WithAIBudgets: Story = { args: { canCreateGroup: true, groupsEnabled: true, + showAIBudget: true, groups: [ - aiGroup("ai-unlimited", "Unlimited"), - aiGroup("ai-under", "Under budget"), - aiGroup("ai-warning", "Near limit"), - aiGroup("ai-at-limit", "At limit"), - aiGroup("ai-over", "Over budget"), - aiGroup("ai-zero-budget", "Zero budget"), - aiGroup("ai-zero-both", "Zero spend and budget"), + 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-no-data", "No data"), ], - aiBudget: { - isLoading: false, - // "ai-no-data" is omitted to exercise the missing-spend "-" fallback. - spend: [ - { - group_id: "ai-unlimited", - current_spend_micros: 25_492_000_000, - spend_limit_micros: null, - }, - { - group_id: "ai-under", - current_spend_micros: 10_000_000, - spend_limit_micros: 50_000_000, - }, - { - group_id: "ai-warning", - current_spend_micros: 46_000_000, - spend_limit_micros: 50_000_000, - }, - { - group_id: "ai-at-limit", - current_spend_micros: 50_000_000, - spend_limit_micros: 50_000_000, - }, - { - group_id: "ai-over", - current_spend_micros: 75_000_000, - spend_limit_micros: 50_000_000, - }, - { - group_id: "ai-zero-budget", - current_spend_micros: 5_000_000, - spend_limit_micros: 0, - }, - { - group_id: "ai-zero-both", - current_spend_micros: 0, - spend_limit_micros: 0, - }, - ], - }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -114,23 +101,23 @@ export const WithAIBudgets: Story = { }, }; +// Groups still loading: the table shows skeleton rows including the AI column. export const WithAIBudgetsLoading: Story = { args: { - groups: [MockGroup], + groups: undefined, canCreateGroup: true, groupsEnabled: true, - aiBudget: { spend: undefined, isLoading: true }, + showAIBudget: true, }, }; -// Spend unavailable (request failed or returned nothing): groups fall back to -// "-". The error toast is fired by the GroupsPage container, not this view. +// Cost control unset for a group: the cell falls back to "-". export const WithAIBudgetsSpendUnavailable: Story = { args: { groups: [aiGroup("ai-unavailable", "Spend unavailable")], canCreateGroup: true, groupsEnabled: true, - aiBudget: { spend: undefined, isLoading: false }, + showAIBudget: true, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -146,7 +133,7 @@ export const WithoutAIBudgetColumn: Story = { groups: [aiGroup("ai-hidden", "No AI column")], canCreateGroup: true, groupsEnabled: true, - aiBudget: undefined, + showAIBudget: false, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 7e0631c336..8c7189e756 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -1,15 +1,13 @@ import { ChevronRightIcon, PlusIcon } from "lucide-react"; import type { FC } from "react"; import { Link as RouterLink, useNavigate } from "react-router"; -import type { OrganizationGroupAISpend } from "#/api/api"; -import type { Group } from "#/api/typesGenerated"; +import type { GroupWithAICostControl } from "#/api/api"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { EmptyState } from "#/components/EmptyState/EmptyState"; -import { InfoTooltip } from "#/components/InfoTooltip/InfoTooltip"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { @@ -25,32 +23,22 @@ import { TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; -import { getSeverity, severityTextClassName } from "#/utils/budget"; -import { microsToDollars, usdBudgetFormatter } from "#/utils/currency"; import { docs } from "#/utils/docs"; +import { AIBudgetUsage } from "./AIBudgetUsage"; +import { InfoIconTooltip } from "./InfoIconTooltip"; type GroupsPageViewProps = { - groups: Group[] | undefined; + groups: GroupWithAICostControl[] | undefined; canCreateGroup: boolean; groupsEnabled: boolean; - // Present when the AI budget column should be shown. - aiBudget?: { - spend: readonly OrganizationGroupAISpend[] | undefined; - isLoading: boolean; - }; -}; - -// Per-group spend resolved for rendering; present only when the column shows. -type AIBudgetColumn = { - spendByGroupID: ReadonlyMap; - isLoading: boolean; + showAIBudget: boolean; }; export const GroupsPageView: FC = ({ groups, canCreateGroup, groupsEnabled, - aiBudget, + showAIBudget, }) => { if (!groupsEnabled) { return ( @@ -62,26 +50,19 @@ export const GroupsPageView: FC = ({ ); } - const aiBudgetColumn: AIBudgetColumn | undefined = aiBudget && { - spendByGroupID: new Map( - aiBudget.spend?.map((spend) => [spend.group_id, spend]), - ), - isLoading: aiBudget.isLoading, - }; - return (
Name - + Users - {aiBudgetColumn && ( + {showAIBudget && (
AI budget - +
)} @@ -92,7 +73,7 @@ export const GroupsPageView: FC = ({
@@ -100,18 +81,18 @@ export const GroupsPageView: FC = ({ }; interface GroupsTableBodyProps { - groups: Group[] | undefined; + groups: GroupWithAICostControl[] | undefined; canCreateGroup: boolean; - aiBudgetColumn: AIBudgetColumn | undefined; + showAIBudget: boolean; } const GroupsTableBody: FC = ({ groups, canCreateGroup, - aiBudgetColumn, + showAIBudget, }) => { if (groups === undefined) { - return ; + return ; } if (groups.length === 0) { return ( @@ -142,22 +123,18 @@ const GroupsTableBody: FC = ({ return ( <> {groups.map((group) => ( - + ))} ); }; interface GroupRowProps { - group: Group; - aiBudgetColumn: AIBudgetColumn | undefined; + group: GroupWithAICostControl; + showAIBudget: boolean; } -const GroupRow: FC = ({ group, aiBudgetColumn }) => { +const GroupRow: FC = ({ group, showAIBudget }) => { const navigate = useNavigate(); const rowProps = useClickableTableRow({ onClick: () => navigate(group.name), @@ -203,12 +180,16 @@ const GroupRow: FC = ({ group, aiBudgetColumn }) => { )} - {aiBudgetColumn && ( + {showAIBudget && ( - + {group.ai_cost_control ? ( + + ) : ( + "-" + )} )} @@ -221,41 +202,6 @@ const GroupRow: FC = ({ group, aiBudgetColumn }) => { ); }; -const GroupAIBudgetCell: FC<{ - aiSpend: OrganizationGroupAISpend | undefined; - isLoading: boolean; -}> = ({ aiSpend, isLoading }) => { - if (isLoading) { - return ; - } - - if (aiSpend === undefined) { - return "-"; - } - - const { current_spend_micros, spend_limit_micros } = aiSpend; - - if (spend_limit_micros === null) { - return ( - - {formatBudgetUSD(current_spend_micros)}{" "} - / unlimited USD - - ); - } - - const severity = getSeverity(current_spend_micros, spend_limit_micros); - return ( - - - {formatBudgetUSD(current_spend_micros)} - {" "} - / {formatBudgetUSD(spend_limit_micros)}{" "} - USD - - ); -}; - const TableLoader: FC<{ showAIBudget: boolean }> = ({ showAIBudget }) => { return ( @@ -280,7 +226,3 @@ const TableLoader: FC<{ showAIBudget: boolean }> = ({ showAIBudget }) => { ); }; - -function formatBudgetUSD(micros: number): string { - return usdBudgetFormatter.format(microsToDollars(micros)); -} diff --git a/site/src/pages/GroupsPage/InfoIconTooltip.stories.tsx b/site/src/pages/GroupsPage/InfoIconTooltip.stories.tsx new file mode 100644 index 0000000000..70d563083b --- /dev/null +++ b/site/src/pages/GroupsPage/InfoIconTooltip.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { InfoIconTooltip } from "./InfoIconTooltip"; + +const meta: Meta = { + title: "pages/OrganizationGroupsPage/InfoIconTooltip", + component: InfoIconTooltip, + args: { message: "Spend compared to the budget for the active period." }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "More info" })); + await expect( + await within(document.body).findByText( + "Spend compared to the budget for the active period.", + ), + ).toBeInTheDocument(); + }, +}; + +// Muted icon, used where it sits next to greyed content. +export const Muted: Story = { + args: { className: "text-content-disabled" }, +}; diff --git a/site/src/pages/GroupsPage/InfoIconTooltip.tsx b/site/src/pages/GroupsPage/InfoIconTooltip.tsx new file mode 100644 index 0000000000..a9d074fb40 --- /dev/null +++ b/site/src/pages/GroupsPage/InfoIconTooltip.tsx @@ -0,0 +1,23 @@ +import { InfoIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { + HelpPopover, + HelpPopoverContent, + HelpPopoverIconTrigger, + HelpPopoverText, +} from "#/components/HelpPopover/HelpPopover"; + +/** An (i) info tooltip. `className` sets the icon color. */ +export const InfoIconTooltip: FC<{ + message: ReactNode; + className?: string; +}> = ({ message, className = "text-content-secondary" }) => ( + + + + + + {message} + + +); diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index 1b002f6556..e26162a8d4 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -116,6 +116,21 @@ export const Uncapped: Story = { ).not.toBeInTheDocument(); }, ); + + await step( + "the empty field flags an error only after it's touched", + async () => { + const budgetInput = body.getByLabelText("Custom monthly budget"); + await expect( + body.queryByText("Enter a monthly budget of 0 or more."), + ).not.toBeInTheDocument(); + await userEvent.click(budgetInput); + await userEvent.tab(); + await expect( + await body.findByText("Enter a monthly budget of 0 or more."), + ).toBeInTheDocument(); + }, + ); }, }; @@ -204,6 +219,8 @@ export const SubmitRequiresValueOrUncheck: Story = { await step("clearing the budget blocks submit", async () => { await userEvent.clear(budgetInput); + // Blur to surface the error, matching the touched-then-validate flow. + await userEvent.tab(); await expect( await body.findByText("Enter a monthly budget of 0 or more."), ).toBeInTheDocument(); diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx index 87a3fa636b..feb0f9942f 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx @@ -9,7 +9,7 @@ import { import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { getErrorDetail } from "#/api/errors"; -import { groupAIBudget, groupsForUser } from "#/api/queries/groups"; +import { groupAIBudget, groupById, groupsForUser } from "#/api/queries/groups"; import { deleteUserAIBudgetOverride, saveUserAIBudgetOverride, @@ -55,8 +55,8 @@ import { Spinner } from "#/components/Spinner/Spinner"; import { cn } from "#/utils/cn"; import { dollarsToMicros, + formatBudgetUSD, microsToDollars, - usdBudgetFormatter, } from "#/utils/currency"; interface UserAIBudgetOverrideDialogProps { @@ -64,12 +64,22 @@ interface UserAIBudgetOverrideDialogProps { onOpenChange: (open: boolean) => void; user: ReducedUser; currentGroup: Group; + effectiveGroupId?: string | null; } export const UserAIBudgetOverrideDialog: FC< UserAIBudgetOverrideDialogProps -> = ({ open, onOpenChange, user, currentGroup }) => { +> = ({ open, onOpenChange, user, currentGroup, effectiveGroupId }) => { const queryClient = useQueryClient(); + const shouldLoadEffectiveGroup = + !!effectiveGroupId && effectiveGroupId !== currentGroup.id; + const effectiveGroupQuery = useQuery({ + ...groupById(effectiveGroupId ?? "", { exclude_members: true }), + enabled: open && shouldLoadEffectiveGroup, + }); + const budgetGroup = shouldLoadEffectiveGroup + ? effectiveGroupQuery.data + : currentGroup; const budgetOverrideQuery = useQuery({ ...userAIBudgetOverride(user.id), enabled: open, @@ -79,8 +89,8 @@ export const UserAIBudgetOverrideDialog: FC< enabled: open, }); const groupBudgetQuery = useQuery({ - ...groupAIBudget(currentGroup.id), - enabled: open, + ...groupAIBudget(budgetGroup?.id ?? currentGroup.id), + enabled: open && budgetGroup !== undefined, }); const saveMutation = useMutation( saveUserAIBudgetOverride(queryClient, user.id), @@ -90,10 +100,12 @@ export const UserAIBudgetOverrideDialog: FC< ); const loadError = + effectiveGroupQuery.error ?? budgetOverrideQuery.error ?? userGroupsQuery.error ?? groupBudgetQuery.error; const isLoading = + effectiveGroupQuery.isLoading || budgetOverrideQuery.isLoading || userGroupsQuery.isLoading || groupBudgetQuery.isLoading; @@ -134,10 +146,15 @@ export const UserAIBudgetOverrideDialog: FC< Loading AI budget... - ) : ( + ) : budgetGroup ? ( onOpenChange(false)} /> - )} + ) : null} ); @@ -155,6 +172,8 @@ export const UserAIBudgetOverrideDialog: FC< interface OverrideFormProps { user: ReducedUser; currentGroup: Group; + // Group marked "(default)" in the picker; null marks none. + defaultGroupId: string | null; override: UserAIBudgetOverride | null; groupBudget: GroupAIBudget | null; userGroups: readonly Group[]; @@ -168,6 +187,7 @@ interface OverrideFormProps { const OverrideForm: FC = ({ user, currentGroup, + defaultGroupId, override, groupBudget, userGroups, @@ -187,6 +207,7 @@ const OverrideForm: FC = ({ const seedMicros = (override ?? groupBudget)?.spend_limit_micros; return seedMicros === undefined ? "" : String(microsToDollars(seedMicros)); }); + const [budgetTouched, setBudgetTouched] = useState(false); const [selectedGroupId, setSelectedGroupId] = useState( override?.group_id ?? currentGroup.id, ); @@ -208,7 +229,8 @@ const OverrideForm: FC = ({ // A "0" budget is valid and disables AI; empty or negative is not. const budgetAmount = Number(budgetDollars); const budgetValid = budgetDollars.trim() !== "" && budgetAmount >= 0; - const budgetInvalid = overrideEnabled && !budgetValid; + // Hold the error until the field is touched, so it doesn't flag immediately. + const budgetInvalid = overrideEnabled && budgetTouched && !budgetValid; const budgetDisablesAI = budgetValid && budgetAmount === 0; // Footer shows only when there's something to save or remove. const showFooter = overrideEnabled || override !== null; @@ -217,7 +239,7 @@ const OverrideForm: FC = ({ !isSubmitting && (overrideEnabled ? budgetValid : override !== null); const groupLabel = (group: Group) => - group.id === currentGroup.id + group.id === defaultGroupId ? `${groupDisplayName(group)} (default)` : groupDisplayName(group); @@ -309,6 +331,7 @@ const OverrideForm: FC = ({ id={budgetId} value={budgetDollars} onChange={(event) => setBudgetDollars(event.target.value)} + onBlur={() => setBudgetTouched(true)} type="number" min="0" step="1" @@ -415,5 +438,4 @@ const Bold: FC<{ children: ReactNode }> = ({ children }) => ( const groupDisplayName = (group: Group): string => group.display_name || group.name; -const formatUSD = (micros: number): string => - `${usdBudgetFormatter.format(microsToDollars(micros))} USD`; +const formatUSD = (micros: number): string => `${formatBudgetUSD(micros)} USD`; diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index c8a3ca7c04..246fe68aca 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -332,15 +332,13 @@ export const handlers = [ // Groups http.get("/api/v2/organizations/:organizationId/groups", () => { - return HttpResponse.json([MockGroup]); - }), - - http.get("/api/v2/organizations/:organizationId/groups/ai/spend", () => { return HttpResponse.json([ { - group_id: MockGroup.id, - current_spend_micros: 25_492_000_000, - spend_limit_micros: null, + ...MockGroup, + ai_cost_control: { + current_spend_micros: 25_492_000_000, + spend_limit_micros: null, + }, }, ]); }), diff --git a/site/src/utils/currency.ts b/site/src/utils/currency.ts index c527830b41..06eca1a4de 100644 --- a/site/src/utils/currency.ts +++ b/site/src/utils/currency.ts @@ -28,6 +28,11 @@ export function microsToDollars(micros: number): number { return micros / MICROS_PER_DOLLAR; } +/** Formats micros as a whole-dollar USD budget, e.g. "$1,345". */ +export function formatBudgetUSD(micros: number): string { + return usdBudgetFormatter.format(microsToDollars(micros)); +} + export function dollarsToMicros(dollars: string | number): number { if (typeof dollars === "string" && dollars.trim() === "") { return 0;