diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index 9e0735314e5..56f6aeec598 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx @@ -97,6 +97,9 @@ const LicensesSettingsPage: FC = () => { aiGovernanceUserFeature={ entitlementsQuery.data?.features.ai_governance_user_limit } + agentRuntimeHoursFeature={ + entitlementsQuery.data?.features.agent_runtime_hours + } refreshEntitlements={async () => { try { await refreshEntitlementsMutation.mutateAsync(); diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx index afcd0065e34..9668b06f0b4 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx @@ -27,6 +27,10 @@ const meta: Meta = { enabled: false, entitlement: "not_entitled", } satisfies Feature, + agentRuntimeHoursFeature: { + enabled: false, + entitlement: "not_entitled", + } satisfies Feature, }, }; @@ -71,3 +75,31 @@ export const ActiveAIGovernanceAddOnUsage: Story = { await expect(canvas.getByText("1,000")).toBeInTheDocument(); }, }; + +/** The Total agent hours panel renders full width, directly above Agent Workspace Builds. */ +export const TotalAgentHoursUsage: Story = { + args: { + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 2000, + soft_limit: 1700, + actual: 435, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const agentHoursHeading = canvas.getByRole("heading", { + name: "Total agent hours", + }); + await expect(canvas.getByText("435")).toBeInTheDocument(); + await expect(canvas.getByText("2,000")).toBeInTheDocument(); + const managedAgentsSection = canvas.getByText( + "Agent Workspace Builds Disabled", + ); + await expect( + agentHoursHeading.compareDocumentPosition(managedAgentsSection) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index 60689401139..6ba76dca465 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -25,6 +25,7 @@ import { LicenseCard } from "./LicenseCard"; import { LicenseSeatConsumptionChart } from "./LicenseSeatConsumptionChart"; import { ManagedAgentsConsumption } from "./ManagedAgentsConsumption"; import { SeatUsageBarCard } from "./SeatUsageBarCard"; +import { TotalAgentHoursCard } from "./TotalAgentHoursCard"; type Props = { showConfetti: boolean; @@ -40,6 +41,7 @@ type Props = { activeUsers: UserStatusChangeCount[] | undefined; managedAgentFeature?: Feature; aiGovernanceUserFeature?: Feature; + agentRuntimeHoursFeature?: Feature; }; const LicensesSettingsPageView: FC = ({ @@ -56,6 +58,7 @@ const LicensesSettingsPageView: FC = ({ activeUsers, managedAgentFeature, aiGovernanceUserFeature, + agentRuntimeHoursFeature, }) => { const theme = useTheme(); const { width, height } = useWindowSize(); @@ -189,6 +192,8 @@ const LicensesSettingsPageView: FC = ({ /> + + diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx new file mode 100644 index 00000000000..5efa5548301 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import type { Feature } from "#/api/typesGenerated"; +import { TotalAgentHoursCard } from "./TotalAgentHoursCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard", + component: TotalAgentHoursCard, + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 400, + } satisfies Feature, + }, +}; + +export default meta; +type Story = StoryObj; + +const hoverInfoIcon = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + await userEvent.hover( + canvas.getByRole("button", { name: "Total agent hours information" }), + ); + return within(canvasElement.ownerDocument.body); +}; + +// Tooltip text appears in both the tooltip popover and its hidden +// accessibility duplicate, so assertions match all occurrences. +const expectTooltipText = async ( + body: ReturnType, + text: RegExp, +) => { + const matches = await body.findAllByText(text); + expect(matches.length).toBeGreaterThanOrEqual(1); +}; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("400")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /Total time agents have been working across all workspaces this license\. A soft-limit warning appears at 85%/, + ); + }, +}; + +export const NoSoftLimit: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + actual: 400, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /^Total time agents have been working across all workspaces this license\.$/, + ); + }, +}; + +export const ReachedSoftLimit: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 850, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("850")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 85% or more of your Total Agent hours for this license\. Agent sessions are still working normally, but you'll want to plan for the 100% limit\./, + ); + }, +}; + +export const ReachedAllocation: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 1000, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText("1,000")).toHaveLength(2); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 100% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./, + ); + }, +}; + +export const OverAllocation: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 1200, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,200")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + }, +}; + +export const MissingActual: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("\u2014")).toBeInTheDocument(); + }, +}; + +export const Disabled: Story = { + args: { + feature: { + enabled: false, + entitlement: "not_entitled", + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.queryByRole("heading", { name: "Total agent hours" }), + ).not.toBeInTheDocument(); + }, +}; + +export const ErrorInvalidLimit: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + actual: 100, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("Invalid license usage limits"), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx new file mode 100644 index 00000000000..fab0036945d --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx @@ -0,0 +1,126 @@ +import { InfoIcon } from "lucide-react"; +import type { FC } from "react"; +import type { Feature } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { cn } from "#/utils/cn"; + +type TotalAgentHoursCardProps = { + feature?: Feature; +}; + +export const TotalAgentHoursCard: FC = ({ + feature, +}) => { + // A zero-hour allocation arrives with enabled=false, which hides the + // panel entirely rather than showing an empty bar. + if (!feature?.enabled) { + return null; + } + + const { limit, soft_limit: softLimit, actual } = feature; + + if (limit === undefined || limit < 0) { + return ( +
+
+ +
+
+ ); + } + + const usedHours = actual ?? 0; + // The backend warns with >= for both thresholds, so "reached" (not + // "exceeded") flips the bar color. + const reachedAllocation = actual !== undefined && usedHours >= limit; + const reachedSoftLimit = + !reachedAllocation && softLimit !== undefined && usedHours >= softLimit; + const usagePercentage = + limit > 0 ? Math.min((usedHours / limit) * 100, 100) : 0; + + const usedLabel = + actual === undefined ? "\u2014" : usedHours.toLocaleString("en-US"); + const limitLabel = limit.toLocaleString("en-US"); + + const softLimitPercent = + softLimit !== undefined && limit > 0 + ? Math.round((softLimit / limit) * 100) + : undefined; + + let tooltip: string; + if (reachedAllocation) { + tooltip = + "You've used 100% of your Total Agent hours for this license. Contact sales to receive more Agent hours."; + } else if (reachedSoftLimit) { + tooltip = `You've used ${softLimitPercent}% or more of your Total Agent hours for this license. Agent sessions are still working normally, but you'll want to plan for the 100% limit.`; + } else if (softLimitPercent !== undefined) { + tooltip = `Total time agents have been working across all workspaces this license. A soft-limit warning appears at ${softLimitPercent}%`; + } else { + tooltip = + "Total time agents have been working across all workspaces this license."; + } + + return ( +
+
+
+
+

Total agent hours

+ + + + + + {tooltip} + + +
+ + +
+
+ ); +};