diff --git a/site/src/components/AIBudgetUsage/AIBudgetUsage.stories.tsx b/site/src/components/AIBudgetUsage/AIBudgetUsage.stories.tsx index e51d53c4aa4..f9d0582749d 100644 --- a/site/src/components/AIBudgetUsage/AIBudgetUsage.stories.tsx +++ b/site/src/components/AIBudgetUsage/AIBudgetUsage.stories.tsx @@ -50,3 +50,11 @@ export const ZeroBudget: Story = { await expect(canvasElement).toHaveTextContent("$5 / $0 USD"); }, }; + +// Zero budget with no spend: shown as-is in the normal color. +export const ZeroBudgetNoSpend: Story = { + args: { currentSpend: 0, spendLimit: 0 }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent("$0 / $0 USD"); + }, +}; diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx b/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx index 98ae0ceda1c..758c8b66929 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx @@ -10,7 +10,11 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { MockGroup2, MockGroupWithoutMembers } from "#/testHelpers/entities"; +import { + MockEveryoneGroup, + MockGroup2, + MockGroupWithoutMembers, +} from "#/testHelpers/entities"; import { GroupMemberBudgetCells } from "./GroupMemberBudgetCells"; const group = MockGroupWithoutMembers; @@ -74,6 +78,7 @@ export const Unlimited: Story = { args: { spend: { ...mockSpend, + group_spend_micros: 1_250_000_000, group_budget: null, effective_group_id: group.organization_id, }, @@ -81,7 +86,7 @@ export const Unlimited: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(await canvas.findByTestId(testId)).toHaveTextContent( - "Unlimited", + "$1,250 / Unlimited USD", ); await expect( canvas.getByText("Everyone (not allocated)"), @@ -91,38 +96,100 @@ export const Unlimited: Story = { }, }; -// Unlimited where the viewed group itself is the effective group. -export const UnlimitedThisGroup: Story = { +// Only the Everyone group can be an effective group without a budget. +export const UnlimitedEveryoneGroup: Story = { args: { + group: MockEveryoneGroup, spend: { ...mockSpend, group_budget: null, + effective_group_id: MockEveryoneGroup.id, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(await canvas.findByTestId(testId)).toHaveTextContent( - "Unlimited", + "$0 / Unlimited USD", ); + await expect( + canvas.getByText("Everyone (not allocated)"), + ).toBeInTheDocument(); + }, +}; + +// The Everyone group's own budget governs; the badge drops "(not allocated)". +export const EveryoneGroupWithBudget: Story = { + args: { + group: MockEveryoneGroup, + spend: { + ...mockSpend, + group_spend_micros: 1_250_000_000, + effective_group_id: MockEveryoneGroup.id, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const cell = await canvas.findByTestId(testId); + await expect(cell).toHaveTextContent("$1,250 USD"); + await expect(cell).toHaveTextContent("Group limit $7,000"); + await expect(canvas.getByText("Everyone")).toBeInTheDocument(); + await expect(canvas.queryByText(/not allocated/)).not.toBeInTheDocument(); + }, +}; + +// A user override resolving to the Everyone group shows as individual. +export const EveryoneGroupIndividual: Story = { + args: { + group: MockEveryoneGroup, + spend: { + ...mockSpend, + group_spend_micros: 1_250_000_000, + effective_group_id: MockEveryoneGroup.id, + group_budget: { + 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("Custom limit $9,000"); + await expect(canvas.getByText("Everyone (individual)")).toBeInTheDocument(); + }, +}; + +// A $0 budget renders like any other limit; no spend keeps the normal color. +export const ZeroBudget: Story = { + args: { + spend: { + ...mockSpend, + group_budget: { spend_limit_micros: 0, limit_source: "group" }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const cell = await canvas.findByTestId(testId); + await expect(cell).toHaveTextContent("$0 USD"); + await expect(cell).toHaveTextContent("Group limit $0"); await expect(canvas.getByText("Front-End")).toBeInTheDocument(); }, }; -export const None: Story = { +// Visual variant of ZeroBudget: spend over a $0 budget takes the exceeded color. +export const ZeroBudgetExceeded: Story = { args: { spend: { ...mockSpend, + group_spend_micros: 100_000_000, group_budget: { spend_limit_micros: 0, limit_source: "group" }, - 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(); + const cell = await canvas.findByTestId(testId); + await expect(cell).toHaveTextContent("$100 USD"); + await expect(cell).toHaveTextContent("Group limit $0"); }, }; @@ -188,13 +255,13 @@ export const NotAttributed: Story = { 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(cell).toHaveTextContent("Budget managed by another 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/), + await body.findByText(/this user's spend in the/), ).toHaveTextContent( - "None of this user's spend counts against the Front-End group. It is managed by the developer group.", + "The amount shown is this user's spend in the Front-End group. Their AI budget is currently managed by the developer group.", ); }, }; diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx b/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx index 9efe4690424..eb692206162 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx @@ -3,6 +3,7 @@ import { useQuery } from "react-query"; import { groupById } from "#/api/queries/groups"; import type { Group, GroupMemberAISpend } from "#/api/typesGenerated"; import { AIBudgetAmount } from "#/components/AIBudgetAmount/AIBudgetAmount"; +import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage"; import { Badge } from "#/components/Badge/Badge"; import { Spinner } from "#/components/Spinner/Spinner"; import { TableCell } from "#/components/Table/Table"; @@ -16,8 +17,8 @@ const OTHER_ORG_MESSAGE = "This user's AI budget is managed by a group in another organization and isn't visible here."; /** - * The AI budget and Budget group cells for a group member. Spend only counts - * against the viewed group; another group's budget shows as unattributed. + * The AI budget and Budget group cells for a group member. Spend is scoped to + * the viewed group; the limit comes from the member's effective group. */ export const GroupMemberBudgetCells: FC<{ group: Group; @@ -50,7 +51,15 @@ export const GroupMemberBudgetCells: FC<{ budgetGroup = EM_DASH; break; case "everyone": - budgetGroup = Everyone (not allocated); + // A populated budget means the Everyone group's own budget applies, + // so it isn't the unallocated fallback. + budgetGroup = ( + + {spend?.group_budget + ? badgeName("Everyone") + : "Everyone (not allocated)"} + + ); break; case "this": budgetGroup = {badgeName(groupName)}; @@ -91,11 +100,11 @@ export const GroupMemberBudgetCells: FC<{ - None of this user's spend counts against the{" "} + The amount shown is this user's spend in the{" "} {groupName} {" "} - group. It is managed by the{" "} + group. Their AI budget is currently managed by the{" "} {effectiveGroupName} {" "} @@ -105,7 +114,7 @@ export const GroupMemberBudgetCells: FC<{ /> - Not attributed to this group + Budget managed by another group ); @@ -116,18 +125,15 @@ export const GroupMemberBudgetCells: FC<{ // The effective group has no budget, so no limit applies. budget = ( + } 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 = ( - - ); } else { const limitLabel = spend.group_budget?.limit_source === "user_override" diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index cf6674b2ed5..95158700332 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3243,7 +3243,7 @@ export const MockGroup3: TypesGen.Group = { total_member_count: 2, }; -const MockEveryoneGroup: TypesGen.Group = { +export const MockEveryoneGroup: TypesGen.Group = { // The "Everyone" group must have the same ID as a the organization it belongs // to. id: MockOrganization.id,