mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add group member AI budget columns (#26574)
Adds AI budget and Budget type columns to the group members table, shown when aibridge is enabled and the ai-gateway-cost-control experiment is on. A member's spend, limit, and source come from an ai_cost_control object embedded in the group members and groups responses, so no extra request is made. - Add AI budget and Budget type columns, gated by the aibridge feature and the ai-gateway-cost-control experiment - Read ai_cost_control inline from the group and member lists instead of calling a separate spend endpoint - Share an AIBudgetUsage component (spend vs budget with severity colors) and an InfoIconTooltip for the column headers - When another group governs a member's budget, grey the spend and name that group in a tooltip; otherwise render the spend (severity-colored) against a white limit - Resolve a member's effective group in the AI budget override dialog, marking only the governing group "(default)" and none when no group governs them - Defer the override's custom-budget error until the field is touched Closes AIGOV-291
This commit is contained in:
+31
-18
@@ -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<TypesGen.Group[]> => {
|
||||
): Promise<GroupWithAICostControl[]> => {
|
||||
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<OrganizationGroupAISpend[]> => {
|
||||
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<TypesGen.Group> => {
|
||||
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<TypesGen.GroupMembersResponse> => {
|
||||
): Promise<GroupMembersResponseWithAICostControl> => {
|
||||
const url = getURLWithSearchParams(
|
||||
`/api/v2/organizations/${organization}/groups/${groupName}/members`,
|
||||
filter,
|
||||
|
||||
@@ -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<Group[]>;
|
||||
};
|
||||
|
||||
const getOrganizationGroupsAISpendQueryKey = (organization: string) => [
|
||||
...getGroupsByOrganizationQueryKey(organization),
|
||||
"aiSpend",
|
||||
];
|
||||
|
||||
export const organizationGroupsAISpend = (organization: string) => {
|
||||
return {
|
||||
queryKey: getOrganizationGroupsAISpendQueryKey(organization),
|
||||
queryFn: () => API.getOrganizationGroupsAISpend(organization),
|
||||
} satisfies UseQueryOptions<OrganizationGroupAISpend[]>;
|
||||
} satisfies UseQueryOptions<GroupWithAICostControl[]>;
|
||||
};
|
||||
|
||||
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<Group> => {
|
||||
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<GroupMembersResponse, UsersRequest> {
|
||||
): UsePaginatedQueryOptions<
|
||||
GroupMembersResponseWithAICostControl,
|
||||
UsersRequest
|
||||
> {
|
||||
return {
|
||||
searchParams,
|
||||
queryPayload: ({ limit, offset }) => {
|
||||
@@ -121,7 +131,11 @@ export function groupsByUserIdInOrganization(organization: string) {
|
||||
return {
|
||||
...groupsByOrganization(organization),
|
||||
select: selectGroupsByUserId,
|
||||
} satisfies UseQueryOptions<Group[], unknown, GroupsByUserId>;
|
||||
} satisfies UseQueryOptions<
|
||||
GroupWithAICostControl[],
|
||||
unknown,
|
||||
GroupsByUserId
|
||||
>;
|
||||
}
|
||||
|
||||
function selectGroupsByUserId(groups: Group[]): GroupsByUserId {
|
||||
|
||||
@@ -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<typeof AIBudgetUsage> = {
|
||||
title: "pages/OrganizationGroupsPage/AIBudgetUsage",
|
||||
component: AIBudgetUsage,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AIBudgetUsage>;
|
||||
|
||||
// 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");
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<span className="whitespace-nowrap">
|
||||
{formatBudgetUSD(currentSpend)}{" "}
|
||||
<span className="text-content-disabled">/ unlimited USD</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const severity = getSeverity(currentSpend, spendLimit);
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
<span className={severityTextClassName(severity)}>
|
||||
{formatBudgetUSD(currentSpend)}
|
||||
</span>{" "}
|
||||
<span className="text-content-primary">
|
||||
/ {formatBudgetUSD(spendLimit)}
|
||||
</span>{" "}
|
||||
<span className="text-content-disabled">USD</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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<ReducedUser | null>(null);
|
||||
const [budgetUser, setBudgetUser] =
|
||||
useState<GroupMemberWithAICostControl | null>(null);
|
||||
|
||||
const { experiments } = useDashboard();
|
||||
// TODO(AIGOV-443): remove the ai-gateway-cost-control experiment gate once
|
||||
@@ -92,8 +99,28 @@ const GroupMembersPage: FC = () => {
|
||||
<Table aria-label="Group members">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-2/5">User</TableHead>
|
||||
<TableHead className="w-3/5">Status</TableHead>
|
||||
<TableHead className={aibridgeVisible ? undefined : "w-2/5"}>
|
||||
User
|
||||
</TableHead>
|
||||
<TableHead className={aibridgeVisible ? undefined : "w-3/5"}>
|
||||
Status
|
||||
</TableHead>
|
||||
{aibridgeVisible && (
|
||||
<>
|
||||
<TableHead>
|
||||
<div className="flex items-center gap-1">
|
||||
AI budget
|
||||
<InfoIconTooltip message="A member's AI spend against their budget for the current period." />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<div className="flex items-center gap-1">
|
||||
Budget type
|
||||
<InfoIconTooltip message="Whether a member's budget comes from their group or an individual override." />
|
||||
</div>
|
||||
</TableHead>
|
||||
</>
|
||||
)}
|
||||
<TableHead className="w-auto" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,10 +272,10 @@ const AddUsersDialog: FC<AddUsersDialogProps> = ({
|
||||
};
|
||||
|
||||
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<GroupMemberRowProps> = ({
|
||||
member,
|
||||
group,
|
||||
canUpdate,
|
||||
aiBudgetVisible,
|
||||
showAIBudget,
|
||||
onManageAIBudget,
|
||||
onRemove,
|
||||
}) => {
|
||||
return (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell width="59%">
|
||||
<TableCell width={showAIBudget ? undefined : "59%"}>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
@@ -280,7 +306,7 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
width="40%"
|
||||
width={showAIBudget ? undefined : "40%"}
|
||||
className={cn(
|
||||
"capitalize",
|
||||
member.status === "suspended" ? "text-content-secondary" : "",
|
||||
@@ -289,7 +315,14 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
<div>{member.status}</div>
|
||||
<LastSeen at={member.last_seen_at} className="text-xs" />
|
||||
</TableCell>
|
||||
<TableCell width="1%">
|
||||
{showAIBudget && (
|
||||
<GroupMemberAIBudgetCells
|
||||
group={group}
|
||||
userID={member.id}
|
||||
costControl={member.ai_cost_control}
|
||||
/>
|
||||
)}
|
||||
<TableCell className="w-1 whitespace-nowrap">
|
||||
{canUpdate && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -299,7 +332,7 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{aiBudgetVisible && (
|
||||
{showAIBudget && (
|
||||
<DropdownMenuItem onClick={onManageAIBudget}>
|
||||
AI Budget
|
||||
</DropdownMenuItem>
|
||||
@@ -319,4 +352,74 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<AIBudgetUsage
|
||||
currentSpend={costControl.current_spend_micros}
|
||||
spendLimit={costControl.spend_limit_micros}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-content-disabled">
|
||||
{formatBudgetUSD(costControl.current_spend_micros)}
|
||||
<MemberBudgetSourceTooltip groupId={costControl.effective_group_id} />
|
||||
</span>
|
||||
);
|
||||
if (onEffectiveGroup && costControl.limit_source) {
|
||||
type = budgetTypeLabels[costControl.limit_source];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableCell
|
||||
data-testid={`member-ai-budget-${userID}`}
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
>
|
||||
{budget}
|
||||
</TableCell>
|
||||
<TableCell>{type}</TableCell>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<InfoIconTooltip
|
||||
className="text-content-disabled"
|
||||
message={
|
||||
name
|
||||
? `This member's AI budget is set by the "${name}" group.`
|
||||
: "This member's AI budget is set by another group."
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const budgetTypeLabels: Record<
|
||||
NonNullable<GroupMemberAICostControl["limit_source"]>,
|
||||
string
|
||||
> = {
|
||||
group: "Group",
|
||||
override: "Individual",
|
||||
};
|
||||
|
||||
export default GroupMembersPage;
|
||||
|
||||
@@ -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<GroupMemberAICostControl> = {},
|
||||
): 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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<typeof useQuery>;
|
||||
|
||||
@@ -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 <EmptyState message="Organization not found" />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<typeof GroupsPageView> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GroupsPageView>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<string, OrganizationGroupAISpend>;
|
||||
isLoading: boolean;
|
||||
showAIBudget: boolean;
|
||||
};
|
||||
|
||||
export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
groups,
|
||||
canCreateGroup,
|
||||
groupsEnabled,
|
||||
aiBudget,
|
||||
showAIBudget,
|
||||
}) => {
|
||||
if (!groupsEnabled) {
|
||||
return (
|
||||
@@ -62,26 +50,19 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const aiBudgetColumn: AIBudgetColumn | undefined = aiBudget && {
|
||||
spendByGroupID: new Map(
|
||||
aiBudget.spend?.map((spend) => [spend.group_id, spend]),
|
||||
),
|
||||
isLoading: aiBudget.isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<Table aria-label="Groups">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-2/5">Name</TableHead>
|
||||
<TableHead className={aiBudgetColumn ? "w-1/5" : "w-3/5"}>
|
||||
<TableHead className={showAIBudget ? "w-1/5" : "w-3/5"}>
|
||||
Users
|
||||
</TableHead>
|
||||
{aiBudgetColumn && (
|
||||
{showAIBudget && (
|
||||
<TableHead className="w-2/5">
|
||||
<div className="flex items-center gap-1">
|
||||
AI budget
|
||||
<InfoTooltip message="Current AI spend compared to the group's AI budget for the active period." />
|
||||
<InfoIconTooltip message="Current AI spend compared to the group's AI budget for the active period." />
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
@@ -92,7 +73,7 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
<GroupsTableBody
|
||||
groups={groups}
|
||||
canCreateGroup={canCreateGroup}
|
||||
aiBudgetColumn={aiBudgetColumn}
|
||||
showAIBudget={showAIBudget}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -100,18 +81,18 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
};
|
||||
|
||||
interface GroupsTableBodyProps {
|
||||
groups: Group[] | undefined;
|
||||
groups: GroupWithAICostControl[] | undefined;
|
||||
canCreateGroup: boolean;
|
||||
aiBudgetColumn: AIBudgetColumn | undefined;
|
||||
showAIBudget: boolean;
|
||||
}
|
||||
|
||||
const GroupsTableBody: FC<GroupsTableBodyProps> = ({
|
||||
groups,
|
||||
canCreateGroup,
|
||||
aiBudgetColumn,
|
||||
showAIBudget,
|
||||
}) => {
|
||||
if (groups === undefined) {
|
||||
return <TableLoader showAIBudget={aiBudgetColumn !== undefined} />;
|
||||
return <TableLoader showAIBudget={showAIBudget} />;
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
@@ -142,22 +123,18 @@ const GroupsTableBody: FC<GroupsTableBodyProps> = ({
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<GroupRow
|
||||
key={group.id}
|
||||
group={group}
|
||||
aiBudgetColumn={aiBudgetColumn}
|
||||
/>
|
||||
<GroupRow key={group.id} group={group} showAIBudget={showAIBudget} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupRowProps {
|
||||
group: Group;
|
||||
aiBudgetColumn: AIBudgetColumn | undefined;
|
||||
group: GroupWithAICostControl;
|
||||
showAIBudget: boolean;
|
||||
}
|
||||
|
||||
const GroupRow: FC<GroupRowProps> = ({ group, aiBudgetColumn }) => {
|
||||
const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
|
||||
const navigate = useNavigate();
|
||||
const rowProps = useClickableTableRow({
|
||||
onClick: () => navigate(group.name),
|
||||
@@ -203,12 +180,16 @@ const GroupRow: FC<GroupRowProps> = ({ group, aiBudgetColumn }) => {
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{aiBudgetColumn && (
|
||||
{showAIBudget && (
|
||||
<TableCell>
|
||||
<GroupAIBudgetCell
|
||||
aiSpend={aiBudgetColumn.spendByGroupID.get(group.id)}
|
||||
isLoading={aiBudgetColumn.isLoading}
|
||||
/>
|
||||
{group.ai_cost_control ? (
|
||||
<AIBudgetUsage
|
||||
currentSpend={group.ai_cost_control.current_spend_micros}
|
||||
spendLimit={group.ai_cost_control.spend_limit_micros}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
@@ -221,41 +202,6 @@ const GroupRow: FC<GroupRowProps> = ({ group, aiBudgetColumn }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const GroupAIBudgetCell: FC<{
|
||||
aiSpend: OrganizationGroupAISpend | undefined;
|
||||
isLoading: boolean;
|
||||
}> = ({ aiSpend, isLoading }) => {
|
||||
if (isLoading) {
|
||||
return <Skeleton variant="text" width="50%" />;
|
||||
}
|
||||
|
||||
if (aiSpend === undefined) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const { current_spend_micros, spend_limit_micros } = aiSpend;
|
||||
|
||||
if (spend_limit_micros === null) {
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{formatBudgetUSD(current_spend_micros)}{" "}
|
||||
<span className="text-content-disabled">/ unlimited USD</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const severity = getSeverity(current_spend_micros, spend_limit_micros);
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
<span className={severityTextClassName(severity)}>
|
||||
{formatBudgetUSD(current_spend_micros)}
|
||||
</span>{" "}
|
||||
/ {formatBudgetUSD(spend_limit_micros)}{" "}
|
||||
<span className="text-content-disabled">USD</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const TableLoader: FC<{ showAIBudget: boolean }> = ({ showAIBudget }) => {
|
||||
return (
|
||||
<TableLoaderSkeleton>
|
||||
@@ -280,7 +226,3 @@ const TableLoader: FC<{ showAIBudget: boolean }> = ({ showAIBudget }) => {
|
||||
</TableLoaderSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
function formatBudgetUSD(micros: number): string {
|
||||
return usdBudgetFormatter.format(microsToDollars(micros));
|
||||
}
|
||||
|
||||
@@ -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<typeof InfoIconTooltip> = {
|
||||
title: "pages/OrganizationGroupsPage/InfoIconTooltip",
|
||||
component: InfoIconTooltip,
|
||||
args: { message: "Spend compared to the budget for the active period." },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InfoIconTooltip>;
|
||||
|
||||
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" },
|
||||
};
|
||||
@@ -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" }) => (
|
||||
<HelpPopover>
|
||||
<HelpPopoverIconTrigger size="small" hoverEffect={false}>
|
||||
<InfoIcon className={className} />
|
||||
</HelpPopoverIconTrigger>
|
||||
<HelpPopoverContent>
|
||||
<HelpPopoverText>{message}</HelpPopoverText>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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<
|
||||
<Spinner loading />
|
||||
Loading AI budget...
|
||||
</div>
|
||||
) : (
|
||||
) : budgetGroup ? (
|
||||
<OverrideForm
|
||||
user={user}
|
||||
currentGroup={currentGroup}
|
||||
currentGroup={budgetGroup}
|
||||
defaultGroupId={
|
||||
effectiveGroupId === undefined
|
||||
? currentGroup.id
|
||||
: effectiveGroupId
|
||||
}
|
||||
override={budgetOverrideQuery.data ?? null}
|
||||
groupBudget={groupBudgetQuery.data ?? null}
|
||||
userGroups={userGroupsQuery.data ?? []}
|
||||
@@ -146,7 +163,7 @@ export const UserAIBudgetOverrideDialog: FC<
|
||||
onRemove={deleteMutation.mutateAsync}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -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<OverrideFormProps> = ({
|
||||
user,
|
||||
currentGroup,
|
||||
defaultGroupId,
|
||||
override,
|
||||
groupBudget,
|
||||
userGroups,
|
||||
@@ -187,6 +207,7 @@ const OverrideForm: FC<OverrideFormProps> = ({
|
||||
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<OverrideFormProps> = ({
|
||||
// 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<OverrideFormProps> = ({
|
||||
!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<OverrideFormProps> = ({
|
||||
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`;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user