mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): polish AI budget members table (#26805)
Finishing touches for the AI cost control group members table.
- Spend stays primary-colored until near the budget limit, via a new
AIBudgetAmount component
- "AI budget period" label shows the current spend window in local
time, next to the members tab
- Budget tooltip notes the reset date and the group's default limit
- "Budget type" renamed to "Budget group", with a badge for the
governing group or override
- Distinguishes $0 budget ("None") from no budget ("Unlimited")
- Unattributed spend from another group shows a note instead of a dash
- "Manage AI budget" disabled only when another named group governs
- Replaces UserAISpend with generated UserAISpendStatus, fixing
limit_source
Closes #26401
This commit is contained in:
+3
-12
@@ -213,7 +213,7 @@ export type GroupMemberAICostControl = Readonly<{
|
||||
current_spend_micros: number;
|
||||
spend_limit_micros: number | null;
|
||||
effective_group_id: string | null;
|
||||
limit_source: "group" | "override" | null;
|
||||
limit_source: TypesGen.AIBudgetLimitSource | null;
|
||||
}>;
|
||||
export type GroupMemberWithAICostControl = TypesGen.ReducedUser &
|
||||
Readonly<{ ai_cost_control?: GroupMemberAICostControl }>;
|
||||
@@ -223,15 +223,6 @@ export type GroupMembersResponseWithAICostControl = Omit<
|
||||
> &
|
||||
Readonly<{ users: readonly GroupMemberWithAICostControl[] }>;
|
||||
|
||||
// TODO(AIGOV-473): drop once generated from codersdk.
|
||||
export type UserAISpend = Readonly<{
|
||||
user_id: string;
|
||||
spend_limit_micros: number | null;
|
||||
effective_group_id: string | null;
|
||||
limit_source: "group" | "override" | null;
|
||||
current_spend_micros: number;
|
||||
}>;
|
||||
|
||||
export function watchInboxNotifications(
|
||||
params?: WatchInboxNotificationsParams,
|
||||
): OneWayWebSocket<TypesGen.GetInboxNotificationResponse> {
|
||||
@@ -584,8 +575,8 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getUserAISpend = async (): Promise<UserAISpend> => {
|
||||
const response = await this.axios.get<UserAISpend>(
|
||||
getUserAISpend = async (): Promise<TypesGen.UserAISpendStatus> => {
|
||||
const response = await this.axios.get<TypesGen.UserAISpendStatus>(
|
||||
"/api/v2/users/me/ai/spend",
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
} from "react-query";
|
||||
import { API, type UserAISpend } from "#/api/api";
|
||||
import { API } from "#/api/api";
|
||||
import { isApiError } from "#/api/errors";
|
||||
import type {
|
||||
AuthorizationRequest,
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
UpsertUserAIBudgetOverrideRequest,
|
||||
User,
|
||||
UserAIBudgetOverride,
|
||||
UserAISpendStatus,
|
||||
UserAppearanceSettings,
|
||||
UserPreferenceSettings,
|
||||
UsersRequest,
|
||||
@@ -159,7 +160,7 @@ export const me = (metadata: MetadataState<User>) => {
|
||||
|
||||
export const meAISpendKey = [...meKey, "aiSpend"] as const;
|
||||
|
||||
export const meAISpend = (): UseQueryOptions<UserAISpend> => {
|
||||
export const meAISpend = (): UseQueryOptions<UserAISpendStatus> => {
|
||||
return {
|
||||
queryKey: meAISpendKey,
|
||||
queryFn: () => API.getUserAISpend(),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
import { getSeverity, type UsageSeverity } from "#/utils/budget";
|
||||
import { formatBudgetUSD } from "#/utils/currency";
|
||||
|
||||
const severityTextClasses = {
|
||||
normal: "text-content-primary",
|
||||
warning: "text-content-warning",
|
||||
exceeded: "text-content-destructive",
|
||||
} as const satisfies Record<UsageSeverity, string>;
|
||||
|
||||
/** A spend amount in USD that takes the warning/exceeded color as it nears the limit; values in micros. */
|
||||
export const AIBudgetAmount: FC<{ spend: number; limit: number }> = ({
|
||||
spend,
|
||||
limit,
|
||||
}) => (
|
||||
<span className={severityTextClasses[getSeverity(spend, limit)]}>
|
||||
{formatBudgetUSD(spend)}
|
||||
</span>
|
||||
);
|
||||
@@ -19,7 +19,7 @@ export const Unlimited: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Well under budget: spend rendered in the normal (secondary) color.
|
||||
// Well under budget: spend emphasized in the primary color, like the limit.
|
||||
export const UnderBudget: Story = {
|
||||
args: { currentSpend: 10_000_000, spendLimit: 50_000_000 },
|
||||
play: async ({ canvasElement }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { getSeverity, severityTextClassName } from "#/utils/budget";
|
||||
import { AIBudgetAmount } from "#/components/AIBudgetAmount/AIBudgetAmount";
|
||||
import { formatBudgetUSD } from "#/utils/currency";
|
||||
|
||||
/** Spend against budget. Highlights spend once it nears or exceeds the limit; values in micros. */
|
||||
@@ -16,12 +16,9 @@ export const AIBudgetUsage: FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const severity = getSeverity(currentSpend, spendLimit);
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
<span className={severityTextClassName(severity)}>
|
||||
{formatBudgetUSD(currentSpend)}
|
||||
</span>{" "}
|
||||
<AIBudgetAmount spend={currentSpend} limit={spendLimit} />{" "}
|
||||
<span className="text-content-primary">
|
||||
/ {formatBudgetUSD(spendLimit)}
|
||||
</span>{" "}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
clampPercentage,
|
||||
severityProgressClassName,
|
||||
type UsageSeverity,
|
||||
} from "#/utils/budget";
|
||||
import { clampPercentage, type UsageSeverity } from "#/utils/budget";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
const severityProgressClasses = {
|
||||
normal: "bg-content-secondary",
|
||||
warning: "bg-content-warning",
|
||||
exceeded: "bg-content-destructive",
|
||||
} as const satisfies Record<UsageSeverity, string>;
|
||||
|
||||
interface UsageBarProps {
|
||||
/** Fraction used, 0-100. Clamped for safety. */
|
||||
percent: number;
|
||||
@@ -38,7 +40,7 @@ export const UsageBar: FC<UsageBarProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-300 ease-out",
|
||||
severityProgressClassName(severity),
|
||||
severityProgressClasses[severity],
|
||||
)}
|
||||
style={{ width: `${clampedPercent}%` }}
|
||||
/>
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
|
||||
import type { UserAISpend } from "#/api/api";
|
||||
import { meAISpendKey } from "#/api/queries/users";
|
||||
import type { Experiment, FeatureName } from "#/api/typesGenerated";
|
||||
import type {
|
||||
Experiment,
|
||||
FeatureName,
|
||||
UserAISpendStatus,
|
||||
} from "#/api/typesGenerated";
|
||||
import { MockBuildInfo, MockUserOwner } from "#/testHelpers/entities";
|
||||
import { withDashboardProvider } from "#/testHelpers/storybook";
|
||||
import { UserDropdown } from "./UserDropdown";
|
||||
|
||||
function mockAISpend(overrides: Partial<UserAISpend> = {}): UserAISpend {
|
||||
return {
|
||||
user_id: MockUserOwner.id,
|
||||
spend_limit_micros: 1_200_000_000,
|
||||
effective_group_id: "grp-789",
|
||||
limit_source: "group",
|
||||
current_spend_micros: 819_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const mockAISpend: UserAISpendStatus = {
|
||||
user_id: MockUserOwner.id,
|
||||
spend_limit_micros: 1_200_000_000,
|
||||
effective_group_id: "grp-789",
|
||||
limit_source: "group",
|
||||
current_spend_micros: 819_000_000,
|
||||
period_start: "2026-06-01T00:00:00Z",
|
||||
period_end: "2026-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const aiSpendQuery = (overrides?: Partial<UserAISpend>) => ({
|
||||
key: meAISpendKey,
|
||||
data: mockAISpend(overrides),
|
||||
});
|
||||
|
||||
// Gates the AI spend section, matching the group budget UI.
|
||||
const aiCostControl: { features: FeatureName[]; experiments: Experiment[] } = {
|
||||
features: ["aibridge"],
|
||||
experiments: ["ai-gateway-cost-control"],
|
||||
@@ -59,7 +55,7 @@ const openDropdown = async (canvasElement: HTMLElement) => {
|
||||
|
||||
const Example: Story = {
|
||||
parameters: {
|
||||
queries: [aiSpendQuery()],
|
||||
queries: [{ key: meAISpendKey, data: mockAISpend }],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("hides AI spend without cost control", async () => {
|
||||
@@ -72,7 +68,7 @@ const Example: Story = {
|
||||
export const WithAISpend: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery()],
|
||||
queries: [{ key: meAISpendKey, data: mockAISpend }],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("shows AI spend", async () => {
|
||||
@@ -88,11 +84,15 @@ export const WithAISpend: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// 90% of the limit lands in the warning band (>=85%, <100%).
|
||||
export const AISpendWarning: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ current_spend_micros: 1_080_000_000 })],
|
||||
queries: [
|
||||
{
|
||||
key: meAISpendKey,
|
||||
data: { ...mockAISpend, current_spend_micros: 1_080_000_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("shows the warning marker near the limit", async () => {
|
||||
@@ -108,11 +108,15 @@ export const AISpendWarning: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Spend exactly at the limit is exceeded (used >= budget).
|
||||
export const AISpendAtLimit: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ current_spend_micros: 1_200_000_000 })],
|
||||
queries: [
|
||||
{
|
||||
key: meAISpendKey,
|
||||
data: { ...mockAISpend, current_spend_micros: 1_200_000_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("marks spend at the limit as exceeded", async () => {
|
||||
@@ -127,11 +131,15 @@ export const AISpendAtLimit: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Spend past the limit clamps the bar to 100% and marks it exceeded.
|
||||
export const AISpendExceeded: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ current_spend_micros: 1_500_000_000 })],
|
||||
queries: [
|
||||
{
|
||||
key: meAISpendKey,
|
||||
data: { ...mockAISpend, current_spend_micros: 1_500_000_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("shows the exceeded marker at the limit", async () => {
|
||||
@@ -147,11 +155,12 @@ export const AISpendExceeded: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// A null limit means unlimited: spend is shown without a progress bar.
|
||||
export const AISpendUnlimited: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ spend_limit_micros: null })],
|
||||
queries: [
|
||||
{ key: meAISpendKey, data: { ...mockAISpend, spend_limit_micros: null } },
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("shows unlimited spend without a bar", async () => {
|
||||
@@ -167,11 +176,12 @@ export const AISpendUnlimited: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// $0 spend against a limit shows an empty bar.
|
||||
export const AISpendZeroSpend: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ current_spend_micros: 0 })],
|
||||
queries: [
|
||||
{ key: meAISpendKey, data: { ...mockAISpend, current_spend_micros: 0 } },
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("shows zero spend with an empty bar", async () => {
|
||||
@@ -186,11 +196,19 @@ export const AISpendZeroSpend: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// $0 limit with $0 spend stays normal, not exceeded.
|
||||
export const AISpendZeroLimit: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ current_spend_micros: 0, spend_limit_micros: 0 })],
|
||||
queries: [
|
||||
{
|
||||
key: meAISpendKey,
|
||||
data: {
|
||||
...mockAISpend,
|
||||
current_spend_micros: 0,
|
||||
spend_limit_micros: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("shows a zero limit without exceeding", async () => {
|
||||
@@ -207,42 +225,49 @@ export const AISpendZeroLimit: Story = {
|
||||
|
||||
// Dropdown closed to isolate the avatar border, which reflects spend severity.
|
||||
|
||||
// No cost control: default border.
|
||||
export const AvatarBorderDisabled: Story = {
|
||||
parameters: {
|
||||
queries: [aiSpendQuery()],
|
||||
queries: [{ key: meAISpendKey, data: mockAISpend }],
|
||||
},
|
||||
};
|
||||
|
||||
// 68% of the limit.
|
||||
export const AvatarBorderNormal: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery()],
|
||||
queries: [{ key: meAISpendKey, data: mockAISpend }],
|
||||
},
|
||||
};
|
||||
|
||||
// 90% of the limit.
|
||||
export const AvatarBorderWarning: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ current_spend_micros: 1_080_000_000 })],
|
||||
queries: [
|
||||
{
|
||||
key: meAISpendKey,
|
||||
data: { ...mockAISpend, current_spend_micros: 1_080_000_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Over the limit.
|
||||
export const AvatarBorderExceeded: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ current_spend_micros: 1_500_000_000 })],
|
||||
queries: [
|
||||
{
|
||||
key: meAISpendKey,
|
||||
data: { ...mockAISpend, current_spend_micros: 1_500_000_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Invalid (negative) spend hides the section.
|
||||
export const AISpendHiddenOnInvalidData: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [aiSpendQuery({ current_spend_micros: -1 })],
|
||||
queries: [
|
||||
{ key: meAISpendKey, data: { ...mockAISpend, current_spend_micros: -1 } },
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("hides AI spend on invalid data", async () => {
|
||||
@@ -252,4 +277,19 @@ export const AISpendHiddenOnInvalidData: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AISpendHiddenOnNegativeLimit: Story = {
|
||||
parameters: {
|
||||
...aiCostControl,
|
||||
queries: [
|
||||
{ key: meAISpendKey, data: { ...mockAISpend, spend_limit_micros: -1 } },
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("hides AI spend on a negative limit", async () => {
|
||||
await openDropdown(canvasElement);
|
||||
expect(screen.queryByText("(AI spend/month)")).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export { Example as UserDropdown };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { meAISpend } from "#/api/queries/users";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import {
|
||||
@@ -7,10 +9,17 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { severityBorderClassName } from "#/utils/budget";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { getSeverity, type UsageSeverity } from "#/utils/budget";
|
||||
import { UserDropdownAISpend } from "./UserDropdownAISpend";
|
||||
import { UserDropdownContent } from "./UserDropdownContent";
|
||||
import { useAISpend } from "./useAISpend";
|
||||
|
||||
const severityBorderClasses = {
|
||||
normal: "border-content-secondary",
|
||||
warning: "border-content-warning",
|
||||
exceeded: "border-content-destructive",
|
||||
} as const satisfies Record<UsageSeverity, string>;
|
||||
|
||||
interface UserDropdownProps {
|
||||
user: TypesGen.User;
|
||||
@@ -25,7 +34,32 @@ export const UserDropdown: FC<UserDropdownProps> = ({
|
||||
supportLinks,
|
||||
onSignOut,
|
||||
}) => {
|
||||
const spend = useAISpend();
|
||||
const { experiments } = useDashboard();
|
||||
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
|
||||
const aibridgeVisible =
|
||||
Boolean(useFeatureVisibility().aibridge) &&
|
||||
experiments.includes("ai-gateway-cost-control");
|
||||
const { data, isError } = useQuery({
|
||||
...meAISpend(),
|
||||
enabled: aibridgeVisible,
|
||||
});
|
||||
|
||||
// A null limit is unlimited and still shown.
|
||||
const hasValidSpend =
|
||||
data !== undefined &&
|
||||
data.current_spend_micros >= 0 &&
|
||||
(data.spend_limit_micros === null || data.spend_limit_micros >= 0);
|
||||
const spend =
|
||||
aibridgeVisible && !isError && hasValidSpend
|
||||
? {
|
||||
currentSpend: data.current_spend_micros,
|
||||
spendLimit: data.spend_limit_micros,
|
||||
}
|
||||
: null;
|
||||
const severity =
|
||||
spend && spend.spendLimit !== null
|
||||
? getSeverity(spend.currentSpend, spend.spendLimit)
|
||||
: "normal";
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -38,9 +72,7 @@ export const UserDropdown: FC<UserDropdownProps> = ({
|
||||
fallback={user.username}
|
||||
src={user.avatar_url}
|
||||
size="lg"
|
||||
className={
|
||||
spend ? severityBorderClassName(spend.severity) : undefined
|
||||
}
|
||||
className={spend ? severityBorderClasses[severity] : undefined}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -50,10 +82,12 @@ export const UserDropdown: FC<UserDropdownProps> = ({
|
||||
user={user}
|
||||
buildInfo={buildInfo}
|
||||
profileExtra={
|
||||
<UserDropdownAISpend
|
||||
spend={spend}
|
||||
header={<DropdownMenuSeparator />}
|
||||
/>
|
||||
spend && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<UserDropdownAISpend {...spend} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
supportLinks={supportLinks}
|
||||
onSignOut={onSignOut}
|
||||
|
||||
@@ -1,47 +1,38 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import type { FC } from "react";
|
||||
import { UsageBar } from "#/components/UsageBar/UsageBar";
|
||||
import { getSeverity, usageProgressPercentage } from "#/utils/budget";
|
||||
import { formatBudgetUSD } from "#/utils/currency";
|
||||
import type { AISpend } from "./useAISpend";
|
||||
|
||||
interface UserDropdownAISpendProps {
|
||||
spend: AISpend | null;
|
||||
/** Rendered above the section, only when the section is shown. */
|
||||
header?: ReactNode;
|
||||
currentSpend: number;
|
||||
/** A null limit means unlimited. */
|
||||
spendLimit: number | null;
|
||||
}
|
||||
|
||||
export const UserDropdownAISpend: FC<UserDropdownAISpendProps> = ({
|
||||
spend,
|
||||
header,
|
||||
currentSpend,
|
||||
spendLimit,
|
||||
}) => {
|
||||
if (!spend) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { currentSpend, spendLimit, percent, severity } = spend;
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<div className="px-2 py-2">
|
||||
<div className="whitespace-nowrap text-sm text-content-primary">
|
||||
{formatBudgetUSD(currentSpend)}{" "}
|
||||
<span className="text-content-secondary">
|
||||
/ {spendLimit === null ? "Unlimited" : formatBudgetUSD(spendLimit)}{" "}
|
||||
USD
|
||||
</span>
|
||||
</div>
|
||||
{spendLimit !== null && (
|
||||
<UsageBar
|
||||
ariaLabel="AI spend usage"
|
||||
percent={percent}
|
||||
severity={severity}
|
||||
className="mt-2 h-2.5"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-1 text-xs text-content-secondary">
|
||||
(AI spend/month)
|
||||
</div>
|
||||
<div className="px-2 py-2">
|
||||
<div className="whitespace-nowrap text-sm text-content-primary">
|
||||
{formatBudgetUSD(currentSpend)}{" "}
|
||||
<span className="text-content-secondary">
|
||||
/ {spendLimit === null ? "Unlimited" : formatBudgetUSD(spendLimit)}{" "}
|
||||
USD
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
{spendLimit !== null && (
|
||||
<UsageBar
|
||||
ariaLabel="AI spend usage"
|
||||
percent={usageProgressPercentage(currentSpend, spendLimit)}
|
||||
severity={getSeverity(currentSpend, spendLimit)}
|
||||
className="mt-2 h-2.5"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-1 text-xs text-content-secondary">
|
||||
(AI spend/month)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useQuery } from "react-query";
|
||||
import { meAISpend } from "#/api/queries/users";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import {
|
||||
getSeverity,
|
||||
type UsageSeverity,
|
||||
usageProgressPercentage,
|
||||
} from "#/utils/budget";
|
||||
|
||||
export interface AISpend {
|
||||
currentSpend: number;
|
||||
/** A null limit means unlimited. */
|
||||
spendLimit: number | null;
|
||||
percent: number;
|
||||
severity: UsageSeverity;
|
||||
}
|
||||
|
||||
/** Resolves AI spend for the avatar border and dropdown section, or null when
|
||||
* it should be hidden. */
|
||||
export function useAISpend(): AISpend | null {
|
||||
const { experiments } = useDashboard();
|
||||
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
|
||||
const aibridgeVisible =
|
||||
useFeatureVisibility().aibridge &&
|
||||
experiments.includes("ai-gateway-cost-control");
|
||||
const { data, isError } = useQuery({
|
||||
...meAISpend(),
|
||||
enabled: aibridgeVisible,
|
||||
});
|
||||
|
||||
if (!aibridgeVisible || isError || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentSpend = data.current_spend_micros;
|
||||
const spendLimit = data.spend_limit_micros;
|
||||
|
||||
// Hide on invalid spend data. A null limit means unlimited, which is shown.
|
||||
if (currentSpend < 0 || (spendLimit !== null && spendLimit < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentSpend,
|
||||
spendLimit,
|
||||
percent:
|
||||
spendLimit === null
|
||||
? 0
|
||||
: usageProgressPercentage(currentSpend, spendLimit),
|
||||
severity:
|
||||
spendLimit === null ? "normal" : getSeverity(currentSpend, spendLimit),
|
||||
};
|
||||
}
|
||||
@@ -29,8 +29,6 @@ import { getUsageLimitPeriodLabel } from "#/pages/AISettingsPage/SpendPage/compo
|
||||
import {
|
||||
clampPercentage,
|
||||
getSeverity,
|
||||
severityRingClassName,
|
||||
severityTextClassName,
|
||||
type UsageSeverity,
|
||||
usageProgressPercentage,
|
||||
} from "#/utils/budget";
|
||||
@@ -48,7 +46,7 @@ type UsageSectionData = {
|
||||
hoverLabel: string;
|
||||
secondaryDetail?: ReactNode;
|
||||
tooltip?: ReactNode;
|
||||
severity?: UsageSeverity;
|
||||
severity: UsageSeverity;
|
||||
};
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("en-US");
|
||||
@@ -183,6 +181,18 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({
|
||||
const RING_SIZE = 28;
|
||||
const RING_STROKE = 1;
|
||||
|
||||
const severityTextClasses = {
|
||||
normal: "text-content-secondary",
|
||||
warning: "text-content-warning",
|
||||
exceeded: "text-content-destructive",
|
||||
} as const satisfies Record<UsageSeverity, string>;
|
||||
|
||||
const severityRingClasses = {
|
||||
normal: "stroke-content-secondary",
|
||||
warning: "stroke-content-warning",
|
||||
exceeded: "stroke-content-destructive",
|
||||
} as const satisfies Record<UsageSeverity, string>;
|
||||
|
||||
const UsageTriggerProgress: FC<{ sections: readonly UsageSectionData[] }> = ({
|
||||
sections,
|
||||
}) => {
|
||||
@@ -233,13 +243,13 @@ const UsageRingProgress: FC<{
|
||||
size={RING_SIZE}
|
||||
strokeWidth={RING_STROKE}
|
||||
percent={clampedPercent}
|
||||
progressClassName={severityRingClassName(severity)}
|
||||
progressClassName={severityRingClasses[severity]}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
severityTextClassName(severity),
|
||||
severityTextClasses[severity],
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
@@ -260,7 +270,7 @@ const UsageSection: FC<{ section: UsageSectionData }> = ({ section }) => {
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs",
|
||||
severityTextClassName(section.severity),
|
||||
severityTextClasses[section.severity],
|
||||
)}
|
||||
>
|
||||
{roundedPercent}%
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { meAISpend } from "#/api/queries/users";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
|
||||
/** The current AI budget window, e.g. "June 1 - July 1, 2026". */
|
||||
export const AIBudgetPeriod: FC = () => {
|
||||
const { experiments } = useDashboard();
|
||||
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
|
||||
const visible =
|
||||
Boolean(useFeatureVisibility().aibridge) &&
|
||||
experiments.includes("ai-gateway-cost-control");
|
||||
const { data: aiSpend } = useQuery({ ...meAISpend(), enabled: visible });
|
||||
|
||||
if (!visible || !aiSpend) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Local time and raw exclusive period_end, matching the spend page.
|
||||
const start = dayjs(aiSpend.period_start).format("MMMM D");
|
||||
const end = dayjs(aiSpend.period_end).format("MMMM D, YYYY");
|
||||
return (
|
||||
<span className="text-sm text-content-secondary">
|
||||
AI budget period: {start} - {end}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, spyOn, userEvent, within } from "storybook/test";
|
||||
import { API, type GroupMemberAICostControl } from "#/api/api";
|
||||
import { getGroupByIdQueryKey } from "#/api/queries/groups";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#/components/Table/Table";
|
||||
import { MockGroup2, MockGroupWithoutMembers } from "#/testHelpers/entities";
|
||||
import { GroupMemberBudgetCells } from "./GroupMemberBudgetCells";
|
||||
|
||||
const group = MockGroupWithoutMembers;
|
||||
const testId = "member-ai-budget-member-1";
|
||||
|
||||
const mockCostControl: GroupMemberAICostControl = {
|
||||
current_spend_micros: 0,
|
||||
spend_limit_micros: 7_000_000_000,
|
||||
effective_group_id: group.id,
|
||||
limit_source: "group",
|
||||
};
|
||||
|
||||
const openInfo = async (canvasElement: HTMLElement) => {
|
||||
const canvas = within(canvasElement);
|
||||
const cell = await canvas.findByTestId(testId);
|
||||
await userEvent.click(
|
||||
within(cell).getByRole("button", { name: "More info" }),
|
||||
);
|
||||
return within(document.body);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof GroupMemberBudgetCells> = {
|
||||
title: "pages/OrganizationGroupsPage/GroupMemberBudgetCells",
|
||||
component: GroupMemberBudgetCells,
|
||||
args: { group, userID: "member-1" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Table aria-label="Member budget">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>AI budget</TableHead>
|
||||
<TableHead>Budget group</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<Story />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GroupMemberBudgetCells>;
|
||||
|
||||
export const NoCostControl: Story = {
|
||||
args: { costControl: undefined },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const cells = canvas.getAllByRole("cell");
|
||||
expect(cells).toHaveLength(2);
|
||||
for (const cell of cells) {
|
||||
await expect(cell).toHaveTextContent("\u2014");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const Unlimited: Story = {
|
||||
args: {
|
||||
costControl: {
|
||||
...mockCostControl,
|
||||
spend_limit_micros: null,
|
||||
effective_group_id: group.organization_id,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByTestId(testId)).toHaveTextContent(
|
||||
"Unlimited",
|
||||
);
|
||||
await expect(
|
||||
canvas.getByText("Everyone (not allocated)"),
|
||||
).toBeInTheDocument();
|
||||
const body = await openInfo(canvasElement);
|
||||
await expect(await body.findByText(/isn't restricted/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A null effective group means no budget applies: unlimited, no badge.
|
||||
* TODO(AIGOV-509): null will instead mean a group in another org.
|
||||
*/
|
||||
export const NoGoverningGroup: Story = {
|
||||
args: {
|
||||
costControl: {
|
||||
...mockCostControl,
|
||||
spend_limit_micros: null,
|
||||
effective_group_id: null,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByTestId(testId)).toHaveTextContent(
|
||||
"Unlimited",
|
||||
);
|
||||
await expect(canvas.getAllByRole("cell")[1]).toHaveTextContent("\u2014");
|
||||
},
|
||||
};
|
||||
|
||||
export const None: Story = {
|
||||
args: {
|
||||
costControl: {
|
||||
...mockCostControl,
|
||||
spend_limit_micros: 0,
|
||||
effective_group_id: group.organization_id,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByTestId(testId)).toHaveTextContent("None");
|
||||
const body = await openInfo(canvasElement);
|
||||
await expect(
|
||||
await body.findByText(/no AI spending allowance/),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Regular: Story = {
|
||||
args: {
|
||||
costControl: { ...mockCostControl, current_spend_micros: 3_235_000_000 },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const cell = await canvas.findByTestId(testId);
|
||||
await expect(cell).toHaveTextContent("$3,235 USD");
|
||||
await expect(cell).toHaveTextContent("Group limit $7,000");
|
||||
await expect(canvas.getByText("Front-End")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Custom: Story = {
|
||||
args: {
|
||||
costControl: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 7_175_000_000,
|
||||
spend_limit_micros: 9_000_000_000,
|
||||
limit_source: "user_override",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const cell = await canvas.findByTestId(testId);
|
||||
await expect(cell).toHaveTextContent("$7,175 USD");
|
||||
await expect(cell).toHaveTextContent("Custom limit $9,000");
|
||||
await expect(
|
||||
canvas.getByText("Front-End (individual)"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// Visual variants of Regular: the amount takes the warning/exceeded color.
|
||||
|
||||
export const NearLimit: Story = {
|
||||
args: {
|
||||
costControl: { ...mockCostControl, current_spend_micros: 6_735_000_000 },
|
||||
},
|
||||
};
|
||||
|
||||
export const OverLimit: Story = {
|
||||
args: {
|
||||
costControl: { ...mockCostControl, current_spend_micros: 7_200_000_000 },
|
||||
},
|
||||
};
|
||||
|
||||
export const NotAttributed: Story = {
|
||||
args: {
|
||||
costControl: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 456_000_000,
|
||||
effective_group_id: MockGroup2.id,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
|
||||
data: MockGroup2,
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const cell = await canvas.findByTestId(testId);
|
||||
await expect(cell).toHaveTextContent("$456 USD");
|
||||
await expect(cell).toHaveTextContent("Not attributed to this group");
|
||||
await expect(await canvas.findByText("developer")).toBeInTheDocument();
|
||||
const body = await openInfo(canvasElement);
|
||||
await expect(
|
||||
await body.findByText(/None of this user's spend counts against/),
|
||||
).toHaveTextContent(
|
||||
"None of this user's spend counts against the Front-End group. It is managed by the developer group.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Spinners while the group name resolves, not a flash of the fallback. */
|
||||
export const ResolvingGroupName: Story = {
|
||||
args: {
|
||||
costControl: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 456_000_000,
|
||||
effective_group_id: MockGroup2.id,
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
// Never settles; the cells stay resolving.
|
||||
spyOn(API, "getGroupById").mockImplementation(() => new Promise(() => {}));
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByTestId(testId);
|
||||
// Both the amount and the badge cell wait for the group name.
|
||||
await expect(canvas.getAllByTitle("Loading spinner")).toHaveLength(2);
|
||||
},
|
||||
};
|
||||
|
||||
export const NotAttributedUnknownGroup: Story = {
|
||||
args: {
|
||||
costControl: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 456_000_000,
|
||||
effective_group_id: "external-group",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: getGroupByIdQueryKey("external-group", { exclude_members: true }),
|
||||
data: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const cell = await canvas.findByTestId(testId);
|
||||
await expect(cell).toHaveTextContent("\u2014");
|
||||
await expect(cell).not.toHaveTextContent("$456");
|
||||
await expect(canvas.getByText("Another org")).toBeInTheDocument();
|
||||
const body = await openInfo(canvasElement);
|
||||
await expect(
|
||||
await body.findByText(/managed by another org and isn't visible here/),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GroupMemberAICostControl } from "#/api/api";
|
||||
import { effectiveBudgetGroup } from "./GroupMemberBudgetCells";
|
||||
|
||||
const group = { id: "group-1", organization_id: "org-1" };
|
||||
|
||||
const mockCostControl: GroupMemberAICostControl = {
|
||||
current_spend_micros: 0,
|
||||
spend_limit_micros: null,
|
||||
effective_group_id: null,
|
||||
limit_source: "group",
|
||||
};
|
||||
|
||||
describe("effectiveBudgetGroup", () => {
|
||||
it("is none without cost control data", () => {
|
||||
expect(effectiveBudgetGroup(undefined, group)).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("is none without a governing group", () => {
|
||||
expect(effectiveBudgetGroup(mockCostControl, group)).toEqual({
|
||||
kind: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("is everyone for the org-wide Everyone group", () => {
|
||||
expect(
|
||||
effectiveBudgetGroup(
|
||||
{ ...mockCostControl, effective_group_id: "org-1" },
|
||||
group,
|
||||
),
|
||||
).toEqual({ kind: "everyone" });
|
||||
});
|
||||
|
||||
it("is everyone when the viewed group is Everyone itself", () => {
|
||||
expect(
|
||||
effectiveBudgetGroup(
|
||||
{ ...mockCostControl, effective_group_id: "org-1" },
|
||||
{ id: "org-1", organization_id: "org-1" },
|
||||
),
|
||||
).toEqual({ kind: "everyone" });
|
||||
});
|
||||
|
||||
it("is this for the given group", () => {
|
||||
expect(
|
||||
effectiveBudgetGroup(
|
||||
{ ...mockCostControl, effective_group_id: "group-1" },
|
||||
group,
|
||||
),
|
||||
).toEqual({ kind: "this" });
|
||||
});
|
||||
|
||||
it("is other for any other group", () => {
|
||||
expect(
|
||||
effectiveBudgetGroup(
|
||||
{ ...mockCostControl, effective_group_id: "group-2" },
|
||||
group,
|
||||
),
|
||||
).toEqual({ kind: "other", groupId: "group-2" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import type { GroupMemberAICostControl } from "#/api/api";
|
||||
import { groupById } from "#/api/queries/groups";
|
||||
import type { Group } from "#/api/typesGenerated";
|
||||
import { AIBudgetAmount } from "#/components/AIBudgetAmount/AIBudgetAmount";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { TableCell } from "#/components/Table/Table";
|
||||
import { formatBudgetUSD } from "#/utils/currency";
|
||||
import { InfoIconTooltip } from "./InfoIconTooltip";
|
||||
|
||||
const EM_DASH = "\u2014";
|
||||
|
||||
/**
|
||||
* The AI budget and Budget group cells for a group member. Spend only counts
|
||||
* against the viewed group; another group's budget shows as unattributed.
|
||||
*/
|
||||
export const GroupMemberBudgetCells: FC<{
|
||||
group: Group;
|
||||
userID: string;
|
||||
costControl: GroupMemberAICostControl | undefined;
|
||||
}> = ({ group, userID, costControl }) => {
|
||||
const effective = effectiveBudgetGroup(costControl, group);
|
||||
const fromOtherGroup = effective.kind === "other";
|
||||
|
||||
const { data: effectiveGroup, isLoading: isResolvingGroupName } = useQuery({
|
||||
...groupById(fromOtherGroup ? effective.groupId : "", {
|
||||
exclude_members: true,
|
||||
}),
|
||||
enabled: fromOtherGroup,
|
||||
});
|
||||
const effectiveGroupName =
|
||||
effectiveGroup?.display_name || effectiveGroup?.name;
|
||||
const groupName = group.display_name || group.name;
|
||||
// A user override shows as "(individual)" on the governing group's badge.
|
||||
const badgeName = (name: string) =>
|
||||
costControl?.limit_source === "user_override"
|
||||
? `${name} (individual)`
|
||||
: name;
|
||||
|
||||
let budgetGroup: ReactNode;
|
||||
switch (effective.kind) {
|
||||
case "none":
|
||||
budgetGroup = EM_DASH;
|
||||
break;
|
||||
case "everyone":
|
||||
budgetGroup = <Badge size="sm">Everyone (not allocated)</Badge>;
|
||||
break;
|
||||
case "this":
|
||||
budgetGroup = <Badge size="sm">{badgeName(groupName)}</Badge>;
|
||||
break;
|
||||
case "other": {
|
||||
// "Another org" when the governing group can't be resolved.
|
||||
const label = effectiveGroupName
|
||||
? badgeName(effectiveGroupName)
|
||||
: "Another org";
|
||||
// Wait for the name to resolve rather than flashing the fallback.
|
||||
budgetGroup = isResolvingGroupName ? (
|
||||
<Spinner loading size="sm" />
|
||||
) : (
|
||||
<Badge size="sm">{label}</Badge>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let budget: ReactNode = EM_DASH;
|
||||
if (costControl && fromOtherGroup) {
|
||||
if (isResolvingGroupName) {
|
||||
budget = <Spinner loading size="sm" />;
|
||||
} else if (!effectiveGroupName) {
|
||||
// The spend hides entirely when the governing group can't be resolved.
|
||||
budget = (
|
||||
<LabelWithInfo
|
||||
label={EM_DASH}
|
||||
message="This user's AI budget is managed by another org and isn't visible here."
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
budget = (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>
|
||||
<span className="text-content-secondary">
|
||||
{formatBudgetUSD(costControl.current_spend_micros)}
|
||||
</span>{" "}
|
||||
<span className="text-content-disabled">USD</span>
|
||||
</span>
|
||||
<InfoIconTooltip
|
||||
message={
|
||||
<>
|
||||
None of this user's spend counts against the{" "}
|
||||
<span className="font-medium text-content-primary">
|
||||
{groupName}
|
||||
</span>{" "}
|
||||
group. It is managed by the{" "}
|
||||
<span className="font-medium text-content-primary">
|
||||
{effectiveGroupName}
|
||||
</span>{" "}
|
||||
group.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-xs text-content-secondary">
|
||||
Not attributed to this group
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (costControl) {
|
||||
const limit = costControl.spend_limit_micros;
|
||||
if (limit === null) {
|
||||
// Also covers a missing governing group: no budget applies.
|
||||
budget = (
|
||||
<LabelWithInfo
|
||||
label="Unlimited"
|
||||
message="None of this user's groups have an AI budget configured, so their AI usage isn't restricted."
|
||||
/>
|
||||
);
|
||||
} else if (limit === 0) {
|
||||
// A $0 budget disables spending, distinct from no budget configured.
|
||||
budget = (
|
||||
<LabelWithInfo
|
||||
label="None"
|
||||
message="This user's group(s) have an AI budget of $0, so they have no AI spending allowance."
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const limitLabel =
|
||||
costControl.limit_source === "user_override" ? "Custom" : "Group";
|
||||
budget = (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>
|
||||
<AIBudgetAmount
|
||||
spend={costControl.current_spend_micros}
|
||||
limit={limit}
|
||||
/>{" "}
|
||||
<span className="text-content-disabled">USD</span>
|
||||
</span>
|
||||
<span className="text-xs text-content-secondary">
|
||||
{`${limitLabel} limit ${formatBudgetUSD(limit)}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableCell
|
||||
data-testid={`member-ai-budget-${userID}`}
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
>
|
||||
{budget}
|
||||
</TableCell>
|
||||
<TableCell>{budgetGroup}</TableCell>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** Which group governs a member's AI budget, relative to the given group. */
|
||||
type EffectiveBudgetGroup =
|
||||
| { kind: "none" }
|
||||
| { kind: "everyone" }
|
||||
| { kind: "this" }
|
||||
| { kind: "other"; groupId: string };
|
||||
|
||||
/**
|
||||
* Resolves which group governs a member's AI budget. "none" means no budget
|
||||
* applies; "everyone" is the org-wide fallback when no named group sets a
|
||||
* budget.
|
||||
*
|
||||
* TODO(AIGOV-509): null will instead mean a group in another org.
|
||||
*/
|
||||
export function effectiveBudgetGroup(
|
||||
costControl: GroupMemberAICostControl | undefined,
|
||||
group: Pick<Group, "id" | "organization_id">,
|
||||
): EffectiveBudgetGroup {
|
||||
const groupId = costControl?.effective_group_id ?? null;
|
||||
if (groupId === null) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
// Everyone shares the org's id; checked first so it wins when the viewed
|
||||
// group is Everyone itself.
|
||||
if (groupId === group.organization_id) {
|
||||
return { kind: "everyone" };
|
||||
}
|
||||
if (groupId === group.id) {
|
||||
return { kind: "this" };
|
||||
}
|
||||
return { kind: "other", groupId };
|
||||
}
|
||||
|
||||
const LabelWithInfo: FC<{ label: ReactNode; message: ReactNode }> = ({
|
||||
label,
|
||||
message,
|
||||
}) => (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<InfoIconTooltip message={message} />
|
||||
</span>
|
||||
);
|
||||
@@ -1,19 +1,17 @@
|
||||
import dayjs from "dayjs";
|
||||
import { EllipsisVerticalIcon, UserPlusIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { type FC, 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 type { GroupMemberWithAICostControl } from "#/api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "#/api/errors";
|
||||
import { addMembers, groupById, removeMember } from "#/api/queries/groups";
|
||||
import { addMembers, groupAIBudget, removeMember } from "#/api/queries/groups";
|
||||
import { meAISpend } from "#/api/queries/users";
|
||||
import type {
|
||||
Group,
|
||||
OrganizationMemberWithUserData,
|
||||
} from "#/api/typesGenerated";
|
||||
import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { AvatarData } from "#/components/Avatar/AvatarData";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
@@ -48,6 +46,10 @@ import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { isEveryoneGroup } from "#/modules/groups";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatBudgetUSD } from "#/utils/currency";
|
||||
import {
|
||||
effectiveBudgetGroup,
|
||||
GroupMemberBudgetCells,
|
||||
} from "./GroupMemberBudgetCells";
|
||||
import type { GroupPageOutletContext } from "./GroupPage";
|
||||
import { InfoIconTooltip } from "./InfoIconTooltip";
|
||||
import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog";
|
||||
@@ -71,11 +73,29 @@ const GroupMembersPage: FC = () => {
|
||||
useState<GroupMemberWithAICostControl | null>(null);
|
||||
|
||||
const { experiments } = useDashboard();
|
||||
// TODO(AIGOV-443): remove the ai-gateway-cost-control experiment gate once
|
||||
// the cost-control feature is stable.
|
||||
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
|
||||
const aibridgeVisible =
|
||||
Boolean(useFeatureVisibility().aibridge) &&
|
||||
experiments.includes("ai-gateway-cost-control");
|
||||
const { data: aiSpend } = useQuery({
|
||||
...meAISpend(),
|
||||
enabled: aibridgeVisible,
|
||||
});
|
||||
const { data: groupBudget } = useQuery({
|
||||
...groupAIBudget(groupData.id),
|
||||
enabled: aibridgeVisible,
|
||||
});
|
||||
const aiBudgetNote = [
|
||||
"Monthly AI spend for this user.",
|
||||
// Spend resets at period_end, rendered in the viewer's local time.
|
||||
aiSpend &&
|
||||
`Resets ${dayjs(aiSpend.period_end).format("MMM D, YYYY h:mm A")}.`,
|
||||
// A $0 default still shows: it means no spending allowance.
|
||||
groupBudget &&
|
||||
`The group's default limit is ${formatBudgetUSD(groupBudget.spend_limit_micros)} per member.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-1 pb-8">
|
||||
@@ -110,13 +130,13 @@ const GroupMembersPage: FC = () => {
|
||||
<TableHead>
|
||||
<div className="flex items-center gap-1">
|
||||
AI budget
|
||||
<InfoIconTooltip message="A member's AI spend against their budget for the current period." />
|
||||
<InfoIconTooltip message={aiBudgetNote} />
|
||||
</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." />
|
||||
Budget group
|
||||
<InfoIconTooltip message="The group or individual budget currently responsible for this user's AI spend. Admins can reassign this at any time, so spend history may span multiple sources." />
|
||||
</div>
|
||||
</TableHead>
|
||||
</>
|
||||
@@ -288,6 +308,10 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
onManageAIBudget,
|
||||
onRemove,
|
||||
}) => {
|
||||
const costControl = member.ai_cost_control;
|
||||
const budgetFromOtherGroup =
|
||||
effectiveBudgetGroup(costControl, group).kind === "other";
|
||||
|
||||
return (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell width={showAIBudget ? undefined : "59%"}>
|
||||
@@ -316,10 +340,10 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
<LastSeen at={member.last_seen_at} className="text-xs" />
|
||||
</TableCell>
|
||||
{showAIBudget && (
|
||||
<GroupMemberAIBudgetCells
|
||||
<GroupMemberBudgetCells
|
||||
group={group}
|
||||
userID={member.id}
|
||||
costControl={member.ai_cost_control}
|
||||
costControl={costControl}
|
||||
/>
|
||||
)}
|
||||
<TableCell className="w-1 whitespace-nowrap">
|
||||
@@ -333,8 +357,11 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{showAIBudget && (
|
||||
<DropdownMenuItem onClick={onManageAIBudget}>
|
||||
AI Budget
|
||||
<DropdownMenuItem
|
||||
onClick={onManageAIBudget}
|
||||
disabled={budgetFromOtherGroup}
|
||||
>
|
||||
Manage AI budget
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
@@ -352,74 +379,4 @@ 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;
|
||||
|
||||
@@ -18,8 +18,15 @@ import {
|
||||
groupPermissionsKey,
|
||||
} from "#/api/queries/groups";
|
||||
import { organizationMembersKey } from "#/api/queries/organizations";
|
||||
import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users";
|
||||
import type { ReducedUser, UserAIBudgetOverride } from "#/api/typesGenerated";
|
||||
import {
|
||||
getUserAIBudgetOverrideQueryKey,
|
||||
meAISpendKey,
|
||||
} from "#/api/queries/users";
|
||||
import type {
|
||||
GroupAIBudget,
|
||||
ReducedUser,
|
||||
UserAISpendStatus,
|
||||
} from "#/api/typesGenerated";
|
||||
import {
|
||||
MockDefaultOrganization,
|
||||
MockGroup,
|
||||
@@ -91,6 +98,17 @@ const membersQuery = (data: unknown) => ({
|
||||
data,
|
||||
});
|
||||
|
||||
/** period_end is exclusive. */
|
||||
const mockUserAISpend: UserAISpendStatus = {
|
||||
user_id: MockUserOwner.id,
|
||||
spend_limit_micros: 9_000_000_000,
|
||||
effective_group_id: MockGroupWithoutMembers.id,
|
||||
limit_source: "group",
|
||||
current_spend_micros: 1_345_000_000,
|
||||
period_start: "2026-06-01T00:00:00Z",
|
||||
period_end: "2026-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GroupPage>;
|
||||
|
||||
@@ -239,33 +257,26 @@ export const FiltersByMembers: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockOwnerOverride: UserAIBudgetOverride = {
|
||||
user_id: MockUserOwner.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",
|
||||
const mockCostControl: GroupMemberAICostControl = {
|
||||
current_spend_micros: 1_345_000_000,
|
||||
spend_limit_micros: 9_000_000_000,
|
||||
effective_group_id: MockGroupWithoutMembers.id,
|
||||
limit_source: "group",
|
||||
};
|
||||
|
||||
// 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,
|
||||
},
|
||||
ai_cost_control: { ...mockCostControl, ...overrides },
|
||||
});
|
||||
|
||||
const memberWithoutSpend: GroupMemberWithAICostControl = {
|
||||
...MockUserMember,
|
||||
id: "no-spend-user",
|
||||
username: "no-spend",
|
||||
const mockGroupBudget: GroupAIBudget = {
|
||||
group_id: MockGroupWithoutMembers.id,
|
||||
spend_limit_micros: 7_000_000_000,
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-01T00:00:00Z",
|
||||
};
|
||||
|
||||
export const WithMemberAIBudget: Story = {
|
||||
@@ -276,42 +287,34 @@ export const WithMemberAIBudget: Story = {
|
||||
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,
|
||||
current_spend_micros: 3_235_000_000,
|
||||
spend_limit_micros: 7_000_000_000,
|
||||
limit_source: "group",
|
||||
}),
|
||||
// No cost control exercises the missing-spend "-" fallback.
|
||||
memberWithoutSpend,
|
||||
],
|
||||
count: 3,
|
||||
count: 1,
|
||||
}),
|
||||
permissionsQuery({ canUpdateGroup: true }),
|
||||
{ key: meAISpendKey, data: mockUserAISpend },
|
||||
{
|
||||
key: groupAIBudget(MockGroupWithoutMembers.id).queryKey,
|
||||
data: mockGroupBudget,
|
||||
},
|
||||
],
|
||||
},
|
||||
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.findByText("Budget group")).toBeInTheDocument();
|
||||
// Dates depend on the runner's timezone; match loosely.
|
||||
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 canvas.findByText(/^AI budget period: \w+ \d+ - \w+ \d+, 2026$/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
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("-");
|
||||
).toHaveTextContent("$3,235 USD");
|
||||
|
||||
// Column header tooltips.
|
||||
const body = within(document.body);
|
||||
await userEvent.click(
|
||||
within(canvas.getByText("AI budget")).getByRole("button", {
|
||||
@@ -320,64 +323,22 @@ export const WithMemberAIBudget: Story = {
|
||||
);
|
||||
await expect(
|
||||
await body.findByText(
|
||||
"A member's AI spend against their budget for the current period.",
|
||||
/^Monthly AI spend for this user\. Resets .*The group's default limit is \$7,000 per member\.$/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
within(canvas.getByText("Budget type")).getByRole("button", {
|
||||
within(canvas.getByText("Budget group")).getByRole("button", {
|
||||
name: "More info",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
await body.findByText(
|
||||
"Whether a member's budget comes from their group or an individual override.",
|
||||
/The group or individual budget currently responsible for this user's AI spend\./,
|
||||
),
|
||||
).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: [
|
||||
@@ -390,11 +351,12 @@ export const WithoutMemberAIBudgetColumn: Story = {
|
||||
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();
|
||||
expect(canvas.queryByText("Budget group")).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/AI budget period/)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenAIBudgetFromMemberMenu: Story = {
|
||||
export const AIBudgetActionDisabledForOtherGroup: Story = {
|
||||
parameters: {
|
||||
features: ["aibridge"],
|
||||
experiments: ["ai-gateway-cost-control"],
|
||||
@@ -405,50 +367,44 @@ export const OpenAIBudgetFromMemberMenu: Story = {
|
||||
memberWithSpend(MockUserOwner, {
|
||||
effective_group_id: MockGroup2.id,
|
||||
}),
|
||||
MockUserMember,
|
||||
],
|
||||
count: 2,
|
||||
count: 1,
|
||||
}),
|
||||
permissionsQuery({ canUpdateGroup: true }),
|
||||
{ key: meAISpendKey, data: mockUserAISpend },
|
||||
{ key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null },
|
||||
{
|
||||
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
|
||||
data: MockGroup2,
|
||||
},
|
||||
{
|
||||
key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id),
|
||||
data: mockOwnerOverride,
|
||||
},
|
||||
{
|
||||
key: getGroupsForUserQueryKey(
|
||||
MockUserOwner.id,
|
||||
MockGroupWithoutMembers.organization_id,
|
||||
),
|
||||
data: [MockGroup],
|
||||
},
|
||||
{
|
||||
key: groupAIBudget(MockGroup2.id).queryKey,
|
||||
data: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(document.body);
|
||||
|
||||
// Without a group default budget, the header note ends at the reset date.
|
||||
await userEvent.click(
|
||||
within(canvas.getByText("AI budget")).getByRole("button", {
|
||||
name: "More info",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
await body.findByText(/^Monthly AI spend for this user\. Resets .*\.$/),
|
||||
).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 expect(
|
||||
await body.findByText("Custom monthly budget"),
|
||||
).toBeInTheDocument();
|
||||
await expect(await body.findByText("developer")).toBeInTheDocument();
|
||||
const menuItem = await body.findByRole("menuitem", {
|
||||
name: "Manage AI budget",
|
||||
});
|
||||
await expect(menuItem).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
// effective_group_id null: spend greys out, dialog marks no "(default)".
|
||||
/** A null effective group means no budget applies: the member is unlimited. */
|
||||
export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
|
||||
parameters: {
|
||||
features: ["aibridge"],
|
||||
@@ -459,12 +415,13 @@ export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
|
||||
users: [
|
||||
memberWithSpend(MockUserOwner, {
|
||||
effective_group_id: null,
|
||||
limit_source: "group",
|
||||
spend_limit_micros: null,
|
||||
}),
|
||||
],
|
||||
count: 1,
|
||||
}),
|
||||
permissionsQuery({ canUpdateGroup: true }),
|
||||
{ key: meAISpendKey, data: mockUserAISpend },
|
||||
{ key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null },
|
||||
{
|
||||
key: getGroupsForUserQueryKey(
|
||||
@@ -483,22 +440,18 @@ export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
|
||||
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 expect(cell).toHaveTextContent("Unlimited");
|
||||
await userEvent.click(
|
||||
within(cell).getByRole("button", { name: "More info" }),
|
||||
);
|
||||
await expect(
|
||||
await body.findByText(/set by another group/),
|
||||
).toBeInTheDocument();
|
||||
await expect(await body.findByText(/isn't restricted/)).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 body.findByRole("menuitem", { name: "Manage AI budget" }),
|
||||
);
|
||||
await userEvent.click(await body.findByText("Override group budget"));
|
||||
await expect(
|
||||
@@ -508,7 +461,6 @@ export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Governed by the viewed group: the dialog marks it "(default)".
|
||||
export const OpenAIBudgetForCurrentGroupMember: Story = {
|
||||
parameters: {
|
||||
features: ["aibridge"],
|
||||
@@ -516,15 +468,11 @@ export const OpenAIBudgetForCurrentGroupMember: Story = {
|
||||
queries: [
|
||||
groupQuery(MockGroupWithoutMembers),
|
||||
groupMembersQuery({
|
||||
users: [
|
||||
memberWithSpend(MockUserOwner, {
|
||||
effective_group_id: MockGroupWithoutMembers.id,
|
||||
limit_source: "group",
|
||||
}),
|
||||
],
|
||||
users: [memberWithSpend(MockUserOwner)],
|
||||
count: 1,
|
||||
}),
|
||||
permissionsQuery({ canUpdateGroup: true }),
|
||||
{ key: meAISpendKey, data: mockUserAISpend },
|
||||
{ key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null },
|
||||
{
|
||||
key: getGroupsForUserQueryKey(
|
||||
@@ -544,7 +492,7 @@ export const OpenAIBudgetForCurrentGroupMember: Story = {
|
||||
canvas.getAllByRole("button", { name: "Open menu" })[0],
|
||||
);
|
||||
await userEvent.click(
|
||||
await body.findByRole("menuitem", { name: "AI Budget" }),
|
||||
await body.findByRole("menuitem", { name: "Manage AI budget" }),
|
||||
);
|
||||
await userEvent.click(await body.findByText("Override group budget"));
|
||||
await expect(
|
||||
@@ -552,3 +500,174 @@ export const OpenAIBudgetForCurrentGroupMember: Story = {
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Unresolvable via getGroupById, standing in for another org's group. */
|
||||
const unresolvedGroupId = "external-org-group";
|
||||
|
||||
/** Per-state details are covered by GroupMemberBudgetCells.stories. */
|
||||
|
||||
export const AIBudgetShowcase: Story = {
|
||||
parameters: {
|
||||
features: ["aibridge"],
|
||||
experiments: ["ai-gateway-cost-control"],
|
||||
queries: [
|
||||
groupQuery(MockGroupWithoutMembers),
|
||||
groupMembersQuery({
|
||||
users: [
|
||||
{
|
||||
...MockUserMember,
|
||||
id: "member-none",
|
||||
username: "alice",
|
||||
name: "Alice Chen",
|
||||
ai_cost_control: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 0,
|
||||
spend_limit_micros: 0,
|
||||
effective_group_id: MockGroupWithoutMembers.organization_id,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockUserMember,
|
||||
id: "member-unlimited",
|
||||
username: "bob",
|
||||
name: "Bob Diaz",
|
||||
ai_cost_control: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 0,
|
||||
spend_limit_micros: null,
|
||||
effective_group_id: MockGroupWithoutMembers.organization_id,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockUserMember,
|
||||
id: "member-elsewhere",
|
||||
username: "priya",
|
||||
name: "Priya Nair",
|
||||
ai_cost_control: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 456_000_000,
|
||||
effective_group_id: unresolvedGroupId,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockUserMember,
|
||||
id: "member-regular",
|
||||
username: "jordan",
|
||||
name: "Jordan Lee",
|
||||
ai_cost_control: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 3_235_000_000,
|
||||
spend_limit_micros: 7_000_000_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockUserMember,
|
||||
id: "member-custom",
|
||||
username: "sam",
|
||||
name: "Sam Okafor",
|
||||
status: "dormant",
|
||||
ai_cost_control: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 7_175_000_000,
|
||||
limit_source: "user_override",
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockUserMember,
|
||||
id: "member-near",
|
||||
username: "morgan",
|
||||
name: "Morgan Ito",
|
||||
ai_cost_control: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 6_735_000_000,
|
||||
spend_limit_micros: 7_000_000_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockUserMember,
|
||||
id: "member-over",
|
||||
username: "casey",
|
||||
name: "Casey Novak",
|
||||
ai_cost_control: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 7_200_000_000,
|
||||
spend_limit_micros: 7_000_000_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockUserMember,
|
||||
id: "member-other-group",
|
||||
username: "riley",
|
||||
name: "Riley Park",
|
||||
status: "suspended",
|
||||
ai_cost_control: {
|
||||
...mockCostControl,
|
||||
current_spend_micros: 456_000_000,
|
||||
effective_group_id: MockGroup2.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 8,
|
||||
}),
|
||||
permissionsQuery({ canUpdateGroup: true }),
|
||||
{ key: meAISpendKey, data: mockUserAISpend },
|
||||
{
|
||||
key: groupAIBudget(MockGroupWithoutMembers.id).queryKey,
|
||||
data: mockGroupBudget,
|
||||
},
|
||||
{
|
||||
key: getGroupByIdQueryKey(unresolvedGroupId, { exclude_members: true }),
|
||||
data: null,
|
||||
},
|
||||
{
|
||||
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
|
||||
data: MockGroup2,
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByRole("table", { name: "Group members" });
|
||||
|
||||
await expect(
|
||||
await canvas.findByTestId("member-ai-budget-member-none"),
|
||||
).toHaveTextContent("None");
|
||||
await expect(
|
||||
await canvas.findByTestId("member-ai-budget-member-unlimited"),
|
||||
).toHaveTextContent("Unlimited");
|
||||
await expect(
|
||||
await canvas.findByTestId("member-ai-budget-member-regular"),
|
||||
).toHaveTextContent("$3,235 USD");
|
||||
await expect(
|
||||
await canvas.findByTestId("member-ai-budget-member-custom"),
|
||||
).toHaveTextContent("$7,175 USD");
|
||||
await expect(
|
||||
await canvas.findByTestId("member-ai-budget-member-other-group"),
|
||||
).toHaveTextContent("Not attributed to this group");
|
||||
|
||||
const elsewhereCell = await canvas.findByTestId(
|
||||
"member-ai-budget-member-elsewhere",
|
||||
);
|
||||
await expect(elsewhereCell).not.toHaveTextContent("$456");
|
||||
|
||||
const body = within(document.body);
|
||||
await userEvent.click(
|
||||
within(
|
||||
await canvas.findByTestId("member-ai-budget-member-none"),
|
||||
).getByRole("button", { name: "More info" }),
|
||||
);
|
||||
await expect(
|
||||
await body.findByText(/no AI spending allowance/),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
// Everyone (unset) must not disable the override action.
|
||||
await userEvent.click(
|
||||
canvas.getAllByRole("button", { name: "Open menu" })[0],
|
||||
);
|
||||
const manageItem = await body.findByRole("menuitem", {
|
||||
name: "Manage AI budget",
|
||||
});
|
||||
await expect(manageItem).not.toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs";
|
||||
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { AIBudgetPeriod } from "./AIBudgetPeriod";
|
||||
|
||||
export type GroupPageOutletContext = {
|
||||
group: Group;
|
||||
@@ -149,8 +150,11 @@ const GroupPage: FC = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
{canUpdateGroup && (
|
||||
<LinkTabs active={activeTab}>
|
||||
<LinkTabsList className="w-full justify-start">
|
||||
<LinkTabs
|
||||
active={activeTab}
|
||||
className="flex items-baseline justify-between"
|
||||
>
|
||||
<LinkTabsList className="justify-start">
|
||||
<TabLink to="." value="members">
|
||||
Group members
|
||||
</TabLink>
|
||||
@@ -158,6 +162,7 @@ const GroupPage: FC = () => {
|
||||
Group settings
|
||||
</TabLink>
|
||||
</LinkTabsList>
|
||||
{activeTab === "members" && <AIBudgetPeriod />}
|
||||
</LinkTabs>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorDetail } from "#/api/errors";
|
||||
import { groupAIBudget, groupById, groupsForUser } from "#/api/queries/groups";
|
||||
import { groupAIBudget, groupsForUser } from "#/api/queries/groups";
|
||||
import {
|
||||
deleteUserAIBudgetOverride,
|
||||
saveUserAIBudgetOverride,
|
||||
@@ -71,15 +71,6 @@ export const UserAIBudgetOverrideDialog: FC<
|
||||
UserAIBudgetOverrideDialogProps
|
||||
> = ({ 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,
|
||||
@@ -89,8 +80,8 @@ export const UserAIBudgetOverrideDialog: FC<
|
||||
enabled: open,
|
||||
});
|
||||
const groupBudgetQuery = useQuery({
|
||||
...groupAIBudget(budgetGroup?.id ?? currentGroup.id),
|
||||
enabled: open && budgetGroup !== undefined,
|
||||
...groupAIBudget(currentGroup.id),
|
||||
enabled: open,
|
||||
});
|
||||
const saveMutation = useMutation(
|
||||
saveUserAIBudgetOverride(queryClient, user.id),
|
||||
@@ -100,12 +91,10 @@ export const UserAIBudgetOverrideDialog: FC<
|
||||
);
|
||||
|
||||
const loadError =
|
||||
effectiveGroupQuery.error ??
|
||||
budgetOverrideQuery.error ??
|
||||
userGroupsQuery.error ??
|
||||
groupBudgetQuery.error;
|
||||
const isLoading =
|
||||
effectiveGroupQuery.isLoading ||
|
||||
budgetOverrideQuery.isLoading ||
|
||||
userGroupsQuery.isLoading ||
|
||||
groupBudgetQuery.isLoading;
|
||||
@@ -115,7 +104,6 @@ export const UserAIBudgetOverrideDialog: FC<
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
// Don't close while a mutation is in flight.
|
||||
if (!isSubmitting) {
|
||||
onOpenChange(nextOpen);
|
||||
}
|
||||
@@ -146,10 +134,10 @@ export const UserAIBudgetOverrideDialog: FC<
|
||||
<Spinner loading />
|
||||
Loading AI budget...
|
||||
</div>
|
||||
) : budgetGroup ? (
|
||||
) : (
|
||||
<OverrideForm
|
||||
user={user}
|
||||
currentGroup={budgetGroup}
|
||||
currentGroup={currentGroup}
|
||||
defaultGroupId={
|
||||
effectiveGroupId === undefined
|
||||
? currentGroup.id
|
||||
@@ -163,7 +151,7 @@ export const UserAIBudgetOverrideDialog: FC<
|
||||
onRemove={deleteMutation.mutateAsync}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -201,8 +189,7 @@ const OverrideForm: FC<OverrideFormProps> = ({
|
||||
const overrideId = useId();
|
||||
|
||||
const [overrideEnabled, setOverrideEnabled] = useState(override !== null);
|
||||
// Seed from the override, else the group budget. Neither (uncapped) seeds
|
||||
// empty, so enabling the override prompts for a value.
|
||||
// Uncapped (no override or group budget) seeds empty, prompting for a value.
|
||||
const [budgetDollars, setBudgetDollars] = useState(() => {
|
||||
const seedMicros = (override ?? groupBudget)?.spend_limit_micros;
|
||||
return seedMicros === undefined ? "" : String(microsToDollars(seedMicros));
|
||||
@@ -232,9 +219,7 @@ const OverrideForm: FC<OverrideFormProps> = ({
|
||||
// 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;
|
||||
// Submittable with a valid amount to write, or an existing override to remove.
|
||||
const canSubmit =
|
||||
!isSubmitting && (overrideEnabled ? budgetValid : override !== null);
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampPercentage,
|
||||
getSeverity,
|
||||
severityBorderClassName,
|
||||
severityProgressClassName,
|
||||
severityRingClassName,
|
||||
severityTextClassName,
|
||||
usageProgressPercentage,
|
||||
} from "./budget";
|
||||
|
||||
@@ -37,48 +33,6 @@ describe("getSeverity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("severityTextClassName", () => {
|
||||
it("maps each severity to its text color, defaulting to normal", () => {
|
||||
expect(severityTextClassName("exceeded")).toBe("text-content-destructive");
|
||||
expect(severityTextClassName("warning")).toBe("text-content-warning");
|
||||
expect(severityTextClassName("normal")).toBe("text-content-secondary");
|
||||
expect(severityTextClassName()).toBe("text-content-secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("severityProgressClassName", () => {
|
||||
it("maps each severity to its progress bar color, defaulting to normal", () => {
|
||||
expect(severityProgressClassName("exceeded")).toBe(
|
||||
"bg-content-destructive",
|
||||
);
|
||||
expect(severityProgressClassName("warning")).toBe("bg-content-warning");
|
||||
expect(severityProgressClassName("normal")).toBe("bg-content-secondary");
|
||||
expect(severityProgressClassName()).toBe("bg-content-secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("severityRingClassName", () => {
|
||||
it("maps each severity to its ring stroke color, defaulting to normal", () => {
|
||||
expect(severityRingClassName("exceeded")).toBe(
|
||||
"stroke-content-destructive",
|
||||
);
|
||||
expect(severityRingClassName("warning")).toBe("stroke-content-warning");
|
||||
expect(severityRingClassName("normal")).toBe("stroke-content-secondary");
|
||||
expect(severityRingClassName()).toBe("stroke-content-secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("severityBorderClassName", () => {
|
||||
it("maps each severity to its border color, defaulting to normal", () => {
|
||||
expect(severityBorderClassName("exceeded")).toBe(
|
||||
"border-content-destructive",
|
||||
);
|
||||
expect(severityBorderClassName("warning")).toBe("border-content-warning");
|
||||
expect(severityBorderClassName("normal")).toBe("border-content-secondary");
|
||||
expect(severityBorderClassName()).toBe("border-content-secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usageProgressPercentage", () => {
|
||||
it("returns the usage percentage clamped from 0 to 100", () => {
|
||||
expect(usageProgressPercentage(25, 100)).toBe(25);
|
||||
|
||||
@@ -18,54 +18,6 @@ export function getSeverity(used: number, budget: number): UsageSeverity {
|
||||
return used / budget >= 0.85 ? "warning" : "normal";
|
||||
}
|
||||
|
||||
const SEVERITY_CLASSES = {
|
||||
normal: {
|
||||
text: "text-content-secondary",
|
||||
progress: "bg-content-secondary",
|
||||
ring: "stroke-content-secondary",
|
||||
border: "border-content-secondary",
|
||||
},
|
||||
warning: {
|
||||
text: "text-content-warning",
|
||||
progress: "bg-content-warning",
|
||||
ring: "stroke-content-warning",
|
||||
border: "border-content-warning",
|
||||
},
|
||||
exceeded: {
|
||||
text: "text-content-destructive",
|
||||
progress: "bg-content-destructive",
|
||||
ring: "stroke-content-destructive",
|
||||
border: "border-content-destructive",
|
||||
},
|
||||
} as const satisfies Record<
|
||||
UsageSeverity,
|
||||
{ text: string; progress: string; ring: string; border: string }
|
||||
>;
|
||||
|
||||
export function severityTextClassName(
|
||||
severity: UsageSeverity = "normal",
|
||||
): string {
|
||||
return SEVERITY_CLASSES[severity].text;
|
||||
}
|
||||
|
||||
export function severityProgressClassName(
|
||||
severity: UsageSeverity = "normal",
|
||||
): string {
|
||||
return SEVERITY_CLASSES[severity].progress;
|
||||
}
|
||||
|
||||
export function severityRingClassName(
|
||||
severity: UsageSeverity = "normal",
|
||||
): string {
|
||||
return SEVERITY_CLASSES[severity].ring;
|
||||
}
|
||||
|
||||
export function severityBorderClassName(
|
||||
severity: UsageSeverity = "normal",
|
||||
): string {
|
||||
return SEVERITY_CLASSES[severity].border;
|
||||
}
|
||||
|
||||
export function usageProgressPercentage(used: number, budget: number): number {
|
||||
if (!Number.isFinite(used) || !Number.isFinite(budget) || budget < 0) {
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user