diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx index afcd0065e34..4f038717cbc 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx @@ -1,7 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, within } from "storybook/test"; import type { Feature } from "#/api/typesGenerated"; -import { MockLicenseResponse } from "#/testHelpers/entities"; +import { + MockAgentRuntimeHoursFeature, + MockLicenseResponse, +} from "#/testHelpers/entities"; import LicensesSettingsPageView from "./LicensesSettingsPageView"; const meta: Meta = { @@ -27,6 +30,10 @@ const meta: Meta = { enabled: false, entitlement: "not_entitled", } satisfies Feature, + agentRuntimeHoursFeature: { + enabled: false, + entitlement: "not_entitled", + } satisfies Feature, }, }; @@ -71,3 +78,32 @@ export const ActiveAIGovernanceAddOnUsage: Story = { await expect(canvas.getByText("1,000")).toBeInTheDocument(); }, }; + +export const TotalAgentHoursUsage: Story = { + args: { + agentRuntimeHoursFeature: { + ...MockAgentRuntimeHoursFeature, + limit: 2000, + soft_limit: 1700, + actual: 435, + // 435 hours and 48 minutes: renders as 435.8. + actual_ms: 435 * 3_600_000 + 48 * 60_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const agentHoursHeading = canvas.getByRole("heading", { + name: "Total agent hours", + }); + const agentHoursCard = within(agentHoursHeading.closest("section")!); + await expect(agentHoursCard.getByText("435.8")).toBeInTheDocument(); + await expect(agentHoursCard.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 5a46201f2e6..b2c99b41950 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; @@ -192,6 +193,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..53e25559b45 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx @@ -0,0 +1,447 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { MockAgentRuntimeHoursFeature } from "#/testHelpers/entities"; +import { TotalAgentHoursCard } from "./TotalAgentHoursCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard", + component: TotalAgentHoursCard, + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + // 400 hours and 18 minutes: renders as 400.3. + actual_ms: 400 * 3_600_000 + 18 * 60_000, + }, + }, +}; + +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); +}; + +// Radix mounts the role="tooltip" node only while the popover is open. +// The node is the visually hidden copy of the content, so there is no +// visibility to assert. +const expectTooltipText = async ( + body: ReturnType, + text: RegExp, +) => { + const tooltip = await body.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(text); +}; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("400.3")).toBeInTheDocument(); + await expect(canvas.getByText(/Warning:/)).toBeInTheDocument(); + await expect(canvas.getByText("850")).toBeInTheDocument(); + await expect(canvas.getByText(/Allocation:/)).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + await expect(canvas.queryByText(/Limit:/)).not.toBeInTheDocument(); + await expect( + canvas.getByText("(June 1, 2026 - May 31, 2027)"), + ).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: { + ...MockAgentRuntimeHoursFeature, + soft_limit: undefined, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText(/Warning:/)).not.toBeInTheDocument(); + 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: { + ...MockAgentRuntimeHoursFeature, + actual: 850, + actual_ms: 850 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("850.0")).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: { + ...MockAgentRuntimeHoursFeature, + actual: 1000, + actual_ms: 1000 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,000.0")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + 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: { + ...MockAgentRuntimeHoursFeature, + actual: 1200, + actual_ms: 1200 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,200.0")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 120% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./, + ); + }, +}; + +// The extra 6 minutes alone push the tenths value past the whole-hour +// allocation and flip the reached state. +export const ReachedAllocationByFraction: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + actual: 1000, + actual_ms: 1000 * 3_600_000 + 6 * 60_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,000.1")).toBeInTheDocument(); + 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\./, + ); + }, +}; + +// Integer claims such as 29/100 must not underflow to 28.9% after +// flooring a binary ratio ((29 / 100) * 100 === 28.999...). +export const SoftLimitExactPercent: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + limit: 100, + soft_limit: 29, + actual: 10, + actual_ms: 10 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("10.0")).toBeInTheDocument(); + await expect(canvas.getByText("29")).toBeInTheDocument(); + await expect(canvas.getByText("100")).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 29%/, + ); + }, +}; + +// 99.9% must never read as a false 100%. +export const NearAllocation: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + soft_limit: 999, + actual: 999, + actual_ms: 999 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("999.0")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 99\.9% 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 MissingActual: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + actual: undefined, + actual_ms: undefined, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("N/A")).toBeInTheDocument(); + }, +}; + +// Missing usage data must not count as reaching a zero soft limit, so +// the tooltip stays informational. +export const MissingActualZeroSoftLimit: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + soft_limit: 0, + actual: undefined, + actual_ms: undefined, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("N/A")).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 0%$/, + ); + }, +}; + +export const MissingUsagePeriod: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + usage_period: undefined, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("400.0")).toBeInTheDocument(); + await expect( + canvas.queryByText("(June 1, 2026 - May 31, 2027)"), + ).not.toBeInTheDocument(); + }, +}; + +export const HardCap: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + hard_limit: 1500, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("400.0")).toBeInTheDocument(); + await expect(canvas.getByText(/Warning:/)).toBeInTheDocument(); + await expect(canvas.getByText("850")).toBeInTheDocument(); + await expect(canvas.getByText(/Allocation:/)).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + await expect(canvas.getByText(/Limit:/)).toBeInTheDocument(); + await expect(canvas.getByText("1,500")).toBeInTheDocument(); + await expect( + canvas.queryByText( + "Agent hours limit reached. Concurrent chats are now limited to 5.", + ), + ).not.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 HardCapBetweenSoftLimitAndLimit: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + hard_limit: 1500, + actual: 900, + actual_ms: 900 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("900.0")).toBeInTheDocument(); + await expect( + canvas.queryByText( + "Agent hours limit reached. Concurrent chats are now limited to 5.", + ), + ).not.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 HardCapBetweenLimitAndHardCap: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + hard_limit: 1500, + actual: 1200, + actual_ms: 1200 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,200.0")).toBeInTheDocument(); + await expect( + canvas.queryByText( + "Agent hours limit reached. Concurrent chats are now limited to 5.", + ), + ).not.toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 120% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./, + ); + }, +}; + +export const ReachedHardCap: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + hard_limit: 1500, + actual: 1600, + actual_ms: 1600 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,600.0")).toBeInTheDocument(); + await expect( + canvas.getByText( + "Agent hours limit reached. Concurrent chats are now limited to 5.", + ), + ).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 160% of your Total Agent hours for this license and reached the hard cap of 1,500 hours\. Contact sales to receive more Agent hours\./, + ); + }, +}; + +// The backend accepts a hard cap equal to the allocation. +export const ReachedCoincidentHardCap: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + hard_limit: 1000, + actual: 1000, + actual_ms: 1000 * 3_600_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,000.0")).toBeInTheDocument(); + await expect( + canvas.getByText( + "Agent hours limit reached. Concurrent chats are now limited to 5.", + ), + ).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 100% of your Total Agent hours for this license and reached the hard cap of 1,000 hours\. Contact sales to receive more Agent hours\./, + ); + }, +}; + +export const Disabled: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + enabled: false, + entitlement: "not_entitled", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.queryByRole("heading", { name: "Total agent hours" }), + ).not.toBeInTheDocument(); + }, +}; + +export const Unlimited: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + limit: undefined, + soft_limit: undefined, + actual: 1200, + // 1,200 hours and 30 minutes: renders as 1,200.5. + actual_ms: 1200 * 3_600_000 + 30 * 60_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,200.5")).toBeInTheDocument(); + await expect(canvas.queryByText(/Warning:/)).not.toBeInTheDocument(); + await expect(canvas.getByText(/Allocation:/)).toBeInTheDocument(); + await expect(canvas.getByText("Unlimited")).toBeInTheDocument(); + await expect( + canvas.queryByText("Invalid license usage limits"), + ).not.toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /^Total time agents have been working across all workspaces this license\.$/, + ); + }, +}; + +export const ErrorInvalidLimit: Story = { + args: { + feature: { + ...MockAgentRuntimeHoursFeature, + // A negative limit can only come from a decoding bug: the + // claim-level unlimited sentinel decodes to an omitted limit, + // never a negative one. + limit: -100, + }, + }, + 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..4a1a6fd982f --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx @@ -0,0 +1,271 @@ +import dayjs from "dayjs"; +import { BanIcon, InfoIcon } from "lucide-react"; +import type { FC } from "react"; +import type { Feature } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Badge } from "#/components/Badge/Badge"; +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, + hard_limit: hardLimit, + actual_ms: actualMs, + usage_period: usagePeriod, + } = feature; + + // An omitted limit means the license grants unlimited runtime hours. + const isUnlimited = limit === undefined; + + if (!isUnlimited && limit < 0) { + return ( +
+
+ +
+
+ ); + } + + const meteredLimit = limit ?? 0; + const usedTenths = + actualMs === undefined ? 0 : Math.floor(actualMs / 360_000); + const usedHours = usedTenths / 10; + const hardCap = + !isUnlimited && + hardLimit !== undefined && + hardLimit > 0 && + hardLimit >= meteredLimit + ? hardLimit + : undefined; + const reachedAllocation = + !isUnlimited && actualMs !== undefined && usedHours >= meteredLimit; + const reachedHardCap = + hardCap !== undefined && actualMs !== undefined && usedHours >= hardCap; + const reachedSoftLimit = + !isUnlimited && + !reachedAllocation && + actualMs !== undefined && + softLimit !== undefined && + usedHours >= softLimit; + const barScale = hardCap ?? meteredLimit; + const usagePercentage = isUnlimited + ? 100 + : barScale > 0 + ? Math.min((usedHours / barScale) * 100, 100) + : 0; + const allocationMarkerPercent = + hardCap === undefined + ? undefined + : Math.min((meteredLimit / hardCap) * 100, 100); + + const softMarkerPercent = + !isUnlimited && softLimit !== undefined && barScale > 0 + ? Math.min((softLimit / barScale) * 100, 100) + : undefined; + const fillClassName = reachedAllocation + ? "bg-highlight-red" + : reachedSoftLimit + ? "bg-border-warning" + : "bg-highlight-green"; + + // Already floored to tenths, so rendering one decimal never rounds. + const usedLabel = + actualMs === undefined + ? "N/A" + : usedHours.toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + const warningLabel = + !isUnlimited && softLimit !== undefined + ? softLimit.toLocaleString("en-US") + : undefined; + const allocationLabel = isUnlimited + ? "Unlimited" + : meteredLimit.toLocaleString("en-US"); + const limitLabel = hardCap?.toLocaleString("en-US"); + + const periodStart = usagePeriod ? dayjs(usagePeriod.start) : undefined; + const periodEnd = usagePeriod ? dayjs(usagePeriod.end) : undefined; + const usagePeriodLabel = + periodStart?.isValid() && periodEnd?.isValid() + ? `(${periodStart.format("MMMM D, YYYY")} - ${periodEnd.format("MMMM D, YYYY")})` + : undefined; + + const formatPercent = (numerator: number, denominator: number): string => + (Math.floor((numerator * 1000) / denominator) / 10).toLocaleString("en-US"); + + const softLimitPercent = + !isUnlimited && softLimit !== undefined && meteredLimit > 0 + ? formatPercent(softLimit, meteredLimit) + : undefined; + + // A fading green fill reads as an unmetered allocation rather than + // 100% usage. The mask needs the -webkit- prefix for Safari. + const unlimitedBarClassName = cn( + "bg-highlight-green", + "[mask-image:linear-gradient(to_right,black_50%,transparent_100%)]", + "[-webkit-mask-image:linear-gradient(to_right,black_50%,transparent_100%)]", + ); + + let tooltip: string; + if (reachedAllocation) { + const usedPercent = + meteredLimit > 0 ? formatPercent(usedTenths, meteredLimit * 10) : "100"; + tooltip = reachedHardCap + ? `You've used ${usedPercent}% of your Total Agent hours for this license and reached the hard cap of ${limitLabel} hours. Contact sales to receive more Agent hours.` + : `You've used ${usedPercent}% 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} + + +
+ {usagePeriodLabel && ( +

+ {usagePeriodLabel} +

+ )} +
+ + {reachedHardCap && ( +
+ {/* The concurrency cap re-engages at usage equal to the + hard cap, so the badge shows at the inclusive boundary + and reads "reached" (still true above it) rather than + "exceeded" (false at equality). The sentence outgrows + narrow cards, so this instance wraps instead of + keeping the shared Badge's single-line layout. */} + + + Agent hours limit reached. Concurrent chats are now limited to + 5. + +
+ )} + +
+ ); +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 71fa81169e8..b988906e6a5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2701,6 +2701,20 @@ export const mockApiError = ({ }, }); +export const MockAgentRuntimeHoursFeature: TypesGen.Feature = { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 400, + actual_ms: 400 * 3_600_000, + usage_period: { + issued_at: "June 1, 2026", + start: "June 1, 2026", + end: "May 31, 2027", + }, +}; + export const MockEntitlements: TypesGen.Entitlements = { errors: [], warnings: [],