From f00afbb9a8ace9bee338156f15be809e08314eda Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 12 Aug 2026 07:18:39 +0000 Subject: [PATCH 1/5] feat(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): add per-license Products section with Coder Agents price gates Each license card now always expands to a Products section: a Coder Workspaces box with active seat usage, and (on Premium licenses) a Coder Agents box driven by the agent_runtime_hours_* license claims and merged agent_runtime_hours entitlement. The Coder Agents box renders five states: no allocation (dashed purple upgrade CTA), unlimited (-1 sentinel), normal usage, allocation exceeded (red border, red 'Agent hours exceeded' status, chats stay Unlimited), and hard limit exceeded (red 'Hard limit exceeded' status, chats capped at 5, mirroring the backend's maxConcurrentRootAgents). Usage and overage indicators follow the AI Governance winning-license pattern via a generalized isLicenseApplicableForFeatureUsage helper. The header gains a Type column (Trial/Standard) and the left label now shows the feature set only (Premium/Enterprise). --- .../AIGovernanceLicensing.ts | 30 +-- .../CoderAgentsProductCard.stories.tsx | 134 ++++++++++++ .../CoderAgentsProductCard.tsx | 177 ++++++++++++++++ .../CoderWorkspacesProductCard.stories.tsx | 63 ++++++ .../CoderWorkspacesProductCard.tsx | 55 +++++ .../LicenseCard.stories.tsx | 200 +++++++++++++++++- .../LicensesSettingsPage/LicenseCard.tsx | 173 ++++++++++----- .../LicensesSettingsPage.tsx | 3 + .../LicensesSettingsPageView.tsx | 3 + .../licenseApplicability.ts | 24 +++ 10 files changed, 780 insertions(+), 82 deletions(-) create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts index cbae3e582a5..bfe1eaa2530 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts @@ -1,6 +1,6 @@ -import dayjs from "dayjs"; import type { GetLicensesResponse } from "#/api/api"; import type { Feature } from "#/api/typesGenerated"; +import { isLicenseApplicableForFeatureUsage } from "./licenseApplicability"; function isPremiumLicense(license: GetLicensesResponse): boolean { return license.claims.feature_set?.toLowerCase() === "premium"; @@ -19,24 +19,6 @@ export function licenseShowsAiGovernanceAddOn( ); } -export function isLicenseApplicableForAiGovernanceOverage( - license: GetLicensesResponse, - aiGovernanceUserFeature: Feature | undefined, -): boolean { - const isExpired = dayjs - .unix(license.claims.license_expires) - .isBefore(dayjs()); - const isNotYetValid = - license.claims.nbf !== undefined && - dayjs.unix(license.claims.nbf).isAfter(dayjs()); - const isAiGovernanceEntitlementInGracePeriod = - aiGovernanceUserFeature?.entitlement === "grace_period"; - - return ( - !isNotYetValid && (!isExpired || isAiGovernanceEntitlementInGracePeriod) - ); -} - export function hasAiGovernanceAddOnLicense( licenses: GetLicensesResponse[] | undefined, aiGovernanceUserFeature: Feature | undefined, @@ -45,10 +27,7 @@ export function hasAiGovernanceAddOnLicense( licenses?.some( (license) => licenseShowsAiGovernanceAddOn(license) && - isLicenseApplicableForAiGovernanceOverage( - license, - aiGovernanceUserFeature, - ), + isLicenseApplicableForFeatureUsage(license, aiGovernanceUserFeature), ) ?? false ); } @@ -65,10 +44,7 @@ function aiGovernanceLimitFromLicenses( .filter( (license) => licenseShowsAiGovernanceAddOn(license) && - isLicenseApplicableForAiGovernanceOverage( - license, - aiGovernanceUserFeature, - ), + isLicenseApplicableForFeatureUsage(license, aiGovernanceUserFeature), ) .map((license) => license.claims.features?.ai_governance_user_limit) .filter((limit): limit is number => limit !== undefined); diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx new file mode 100644 index 00000000000..0f51a52a54b --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard", + component: CoderAgentsProductCard, + args: { + allocation: 20000, + actual: 16264, + isExceeded: false, + isHardLimitExceeded: false, + }, +}; + +export default meta; +type Story = StoryObj; + +const getMetricValue = (canvas: ReturnType, label: string) => + canvas.getByText(label).parentElement?.nextElementSibling; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + const manageUsage = canvas.getByRole("link", { name: "Manage usage" }); + await expect(manageUsage).toHaveAttribute("href", "/deployment/groups"); + const agentSettings = canvas.getByRole("link", { name: "Agent settings" }); + await expect(agentSettings).toHaveAttribute( + "href", + "/ai/settings/coder-agents", + ); + }, +}; + +export const UnlimitedAllocation: Story = { + args: { + allocation: -1, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "Unlimited", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const NotProvidingUsage: Story = { + args: { + actual: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 20,000", + ); + }, +}; + +export const Exceeded: Story = { + args: { + actual: 21000, + isExceeded: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "21,000 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const HardLimitExceeded: Story = { + args: { + actual: 25000, + isHardLimitExceeded: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "25,000 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); + }, +}; + +export const NoAllocation: Story = { + args: { + allocation: undefined, + actual: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + getMetricValue(canvas, "Max concurrent chats"), + ).toHaveTextContent("5"); + await expect( + canvas.queryByText(/Agent hours used/), + ).not.toBeInTheDocument(); + const upgrade = canvas.getByRole("link", { name: "Upgrade" }); + await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); + }, +}; + +export const NoAllocationWithUsage: Story = { + args: { + allocation: undefined, + actual: 1234, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( + "Agent hours used: 1,234", + ); + await expect( + canvas.getByRole("link", { name: "Upgrade" }), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx new file mode 100644 index 00000000000..3154054b4f3 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -0,0 +1,177 @@ +import { InfoIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { Link as RouterLink } from "react-router"; +import { Button } from "#/components/Button/Button"; +import { Link } from "#/components/Link/Link"; +import { Separator } from "#/components/Separator/Separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { cn } from "#/utils/cn"; + +// Sentinel allocation claim value meaning the license grants unlimited +// agent runtime hours (AgentRuntimeHoursUnlimitedAllocation in +// enterprise/coderd/license). +const unlimitedAllocation = -1; + +// Mirrors the backend's maxConcurrentRootAgents constant, which caps +// concurrent chats once the hard limit is reached. It is not exposed via +// the API, so keep this value in sync with the backend. +const maxConcurrentChatsOverHardLimit = 5; + +type CoderAgentsProductCardProps = { + /** + * The license's agent_runtime_hours_allocation claim, in hours. + * Undefined or non-positive (other than the -1 unlimited sentinel) + * means the license does not include Coder Agents hours. + */ + allocation?: number; + /** + * Agent runtime hours used in the current usage period, from the + * merged entitlements. Undefined when usage does not apply to this + * license (another license provides the feature) or is unknown. + */ + actual?: number; + /** Usage is above this license's allocation. */ + isExceeded: boolean; + /** Usage is at or above this license's hard limit. */ + isHardLimitExceeded: boolean; +}; + +const MetricLabel: FC<{ label: string; tooltip: string }> = ({ + label, + tooltip, +}) => ( +
+ {label} + + + + + + {tooltip} + + +
+); + +const CardContainer: FC<{ className?: string; children: ReactNode }> = ({ + className, + children, +}) => ( +
+
Coder Agents
+ {children} +
+); + +// TODO: placeholder tooltip copy pending product review. +const totalAgentHoursTooltip = + "Total agent runtime hours used out of the hours included in this license."; +const concurrentChatsTooltip = + "Number of Coder Agents chats that can run at the same time."; + +export const CoderAgentsProductCard: FC = ({ + allocation, + actual, + isExceeded, + isHardLimitExceeded, +}) => { + const isUnlimited = allocation === unlimitedAllocation; + const grantsAgentHours = + allocation !== undefined && (allocation > 0 || isUnlimited); + + if (!grantsAgentHours) { + return ( + +
+ +
+ {maxConcurrentChatsOverHardLimit} +
+ {actual !== undefined && ( +
+ Agent hours used:{" "} + + {actual.toLocaleString("en-US")} + +
+ )} +
+ +
+ ); + } + + const isOverage = isExceeded || isHardLimitExceeded; + const actualLabel = + actual === undefined ? "\u2014" : actual.toLocaleString("en-US"); + + return ( + +
+
+ +
+ {isUnlimited ? ( + "Unlimited" + ) : ( + <> + + {actualLabel} + {" "} + / {allocation.toLocaleString("en-US")} + + )} +
+
+
+ +
+ {isHardLimitExceeded + ? maxConcurrentChatsOverHardLimit + : "Unlimited"} +
+
+
+
+ + Manage usage + + + + Agent settings + +
+
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx new file mode 100644 index 00000000000..805e4dde932 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { CoderWorkspacesProductCard } from "./CoderWorkspacesProductCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard", + component: CoderWorkspacesProductCard, + args: { + userLimitActual: 4, + userLimitLimit: 10, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Coder Workspaces")).toBeInTheDocument(); + const usageLabel = canvas.getByText("Active seat usage"); + const usageValue = usageLabel.parentElement?.nextElementSibling; + await expect(usageValue).toHaveTextContent("4 / 10"); + }, +}; + +export const UnlimitedSeats: Story = { + args: { + userLimitLimit: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const usageLabel = canvas.getByText("Active seat usage"); + const usageValue = usageLabel.parentElement?.nextElementSibling; + await expect(usageValue).toHaveTextContent("4 / Unlimited"); + }, +}; + +export const NoUsageData: Story = { + args: { + userLimitActual: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const usageLabel = canvas.getByText("Active seat usage"); + const usageValue = usageLabel.parentElement?.nextElementSibling; + await expect(usageValue).toHaveTextContent("\u2014 / 10"); + }, +}; + +export const LargeCounts: Story = { + args: { + userLimitActual: 1923, + userLimitLimit: 2500, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const usageLabel = canvas.getByText("Active seat usage"); + const usageValue = usageLabel.parentElement?.nextElementSibling; + await expect(usageValue).toHaveTextContent("1,923 / 2,500"); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx new file mode 100644 index 00000000000..d2428a70370 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx @@ -0,0 +1,55 @@ +import { InfoIcon } from "lucide-react"; +import type { FC } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; + +type CoderWorkspacesProductCardProps = { + userLimitActual?: number; + userLimitLimit?: number; +}; + +export const CoderWorkspacesProductCard: FC< + CoderWorkspacesProductCardProps +> = ({ userLimitActual, userLimitLimit }) => { + const actualLabel = + userLimitActual === undefined + ? "\u2014" + : userLimitActual.toLocaleString("en-US"); + const limitLabel = userLimitLimit + ? userLimitLimit.toLocaleString("en-US") + : "Unlimited"; + + return ( +
+
+ Coder Workspaces +
+
+
+ Active seat usage + + + + + + Only Active user accounts consume license seats. Dormant and + suspended accounts don't count toward the total. + + +
+
+ {actualLabel} / {limitLabel} +
+
+
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index 88cc7a32081..11022111d89 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import dayjs from "dayjs"; -import { expect, fn, within } from "storybook/test"; +import { expect, fn, waitFor, within } from "storybook/test"; import { MockLicenseResponse } from "#/testHelpers/entities"; import { LicenseCard } from "./LicenseCard"; @@ -23,12 +23,53 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const getMetricValue = (canvas: ReturnType, label: string) => + canvas.getByText(label).parentElement?.nextElementSibling; + export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("#1")).toBeInTheDocument(); - await expect(canvas.getByText("4 / 10")).toBeInTheDocument(); + // The Users header field and the Coder Workspaces product card show + // the same seat usage. + await expect(canvas.getAllByText("4 / 10")).toHaveLength(2); await expect(canvas.getByText("Enterprise")).toBeInTheDocument(); + await expect(canvas.getByText("Standard")).toBeInTheDocument(); + await expect(canvas.getByText("Products")).toBeInTheDocument(); + await expect(canvas.getByText("Coder Workspaces")).toBeInTheDocument(); + // Enterprise licenses do not get the Coder Agents product. + await expect(canvas.queryByText("Coder Agents")).not.toBeInTheDocument(); + }, +}; + +export const CollapsesProducts: Story = { + play: async ({ canvasElement, userEvent }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Products")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: /#1/ })); + await waitFor(() => + expect(canvas.queryByText("Products")).not.toBeInTheDocument(), + ); + await userEvent.click(canvas.getByRole("button", { name: /#1/ })); + await waitFor(() => expect(canvas.getByText("Products")).toBeVisible()); + }, +}; + +export const Trial: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + trial: true, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Premium")).toBeInTheDocument(); + const typeLabel = canvas.getByText("Type"); + await expect(typeLabel.nextElementSibling).toHaveTextContent("Trial"); }, }; @@ -38,7 +79,7 @@ export const UnlimitedUsers: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("4 / Unlimited")).toBeInTheDocument(); + await expect(canvas.getAllByText("4 / Unlimited")).toHaveLength(2); }, }; @@ -59,7 +100,7 @@ export const UsesLicenseUserLimit: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("1 / 3")).toBeInTheDocument(); + await expect(canvas.getAllByText("1 / 3")).toHaveLength(2); }, }; @@ -67,6 +108,157 @@ export const Premium: Story = { args: { license: MockLicenseResponse[1], }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A Premium license with no agent hours allocation shows the Coder + // Agents upgrade card. + await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect( + getMetricValue(canvas, "Max concurrent chats"), + ).toHaveTextContent("5"); + const upgrade = canvas.getByRole("link", { name: "Upgrade" }); + await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); + }, +}; + +const premiumLicenseWithAgentHours = (allocation: number) => ({ + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + features: { + ...MockLicenseResponse[1].claims.features, + agent_runtime_hours_allocation: allocation, + ...(allocation > 0 + ? { + agent_runtime_hours_limit_soft: Math.floor(allocation * 0.8), + agent_runtime_hours_limit_hard: Math.floor(allocation * 1.25), + } + : {}), + }, + }, +}); + +export const PremiumWithAgentHours: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 16264, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + await expect( + canvas.getByRole("link", { name: "Manage usage" }), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: "Agent settings" }), + ).toBeInTheDocument(); + }, +}; + +export const PremiumWithAgentHoursExceeded: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 21000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Agent hours exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "21,000 / 20,000", + ); + // Concurrency is only capped once the hard limit is reached. + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const PremiumWithAgentHoursHardLimitExceeded: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 25000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Hard limit exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "25,000 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); + }, +}; + +export const PremiumWithUnlimitedAgentHours: Story = { + args: { + license: premiumLicenseWithAgentHours(-1), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + actual: 16264, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "Unlimited", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const LowerAgentHoursCardUsesMergedEntitlement: Story = { + args: { + license: premiumLicenseWithAgentHours(10000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + actual: 16264, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Usage belongs to the winning 20,000-hour license, so this card + // shows no usage and no overage. + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 10,000", + ); + await expect( + canvas.queryByText("Agent hours exceeded"), + ).not.toBeInTheDocument(); + }, }; export const PremiumWithAIGovernance: Story = { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index b4667e4c9a4..764717094c6 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -18,14 +18,15 @@ import { } from "#/components/DropdownMenu/DropdownMenu"; import { cn } from "#/utils/cn"; import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard"; -import { - isLicenseApplicableForAiGovernanceOverage, - licenseShowsAiGovernanceAddOn, -} from "./AIGovernanceLicensing"; +import { licenseShowsAiGovernanceAddOn } from "./AIGovernanceLicensing"; +import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; +import { CoderWorkspacesProductCard } from "./CoderWorkspacesProductCard"; +import { isLicenseApplicableForFeatureUsage } from "./licenseApplicability"; type LicenseCardProps = { license: GetLicensesResponse; aiGovernanceUserFeature?: Feature; + agentRuntimeHoursFeature?: Feature; userLimitActual?: number; userLimitLimit?: number; onRemove: (licenseId: number) => void; @@ -35,6 +36,7 @@ type LicenseCardProps = { export const LicenseCard: FC = ({ license, aiGovernanceUserFeature, + agentRuntimeHoursFeature, userLimitActual, userLimitLimit, onRemove, @@ -59,15 +61,11 @@ export const LicenseCard: FC = ({ const aiGovernanceLimit = license.claims.features?.ai_governance_user_limit ?? 0; - const licenseType = license.claims.trial - ? "Trial" - : isPremium - ? "Premium" - : "Enterprise"; + const licenseType = isPremium ? "Premium" : "Enterprise"; const hasExplicitAiGovernanceAddOn = licenseShowsAiGovernanceAddOn(license); // Overage/display checks only apply to licenses that are currently effective. - const isLicenseApplicable = isLicenseApplicableForAiGovernanceOverage( + const isLicenseApplicable = isLicenseApplicableForFeatureUsage( license, aiGovernanceUserFeature, ); @@ -89,26 +87,80 @@ export const LicenseCard: FC = ({ const aiGovernanceDisplayActual = canUseAiGovernanceUsageForThisLicense ? aiGovernanceActual : undefined; + + // Agent runtime hour claims, in hours. The -1 allocation is the + // unlimited sentinel; other non-positive allocations do not grant the + // feature. The hard limit only applies to positive allocations it is + // at or above, mirroring the backend's claim validation. + const agentHoursAllocation = + license.claims.features.agent_runtime_hours_allocation; + const agentHoursHardLimit = + license.claims.features.agent_runtime_hours_limit_hard; + const licenseGrantsAgentHours = + agentHoursAllocation !== undefined && + (agentHoursAllocation > 0 || agentHoursAllocation === -1); + const isAgentHoursLicenseApplicable = isLicenseApplicableForFeatureUsage( + license, + agentRuntimeHoursFeature, + ); + // A license "wins" when its allocation matches the merged entitlement: + // equal limits, or an unlimited allocation with the merged limit omitted. + const isWinningAgentHoursLicense = + agentHoursAllocation === -1 + ? agentRuntimeHoursFeature?.enabled === true && + agentRuntimeHoursFeature.limit === undefined + : agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursAllocation === agentRuntimeHoursFeature?.limit; + const canUseAgentHoursUsageForThisLicense = + isAgentHoursLicenseApplicable && isWinningAgentHoursLicense; + // Usage applies to the winning license's quota. Licenses without an + // allocation show deployment-wide usage in their upgrade card instead. + const agentHoursDisplayActual = + isAgentHoursLicenseApplicable && + (isWinningAgentHoursLicense || !licenseGrantsAgentHours) + ? agentRuntimeHoursFeature?.actual + : undefined; + const isAgentHoursHardLimitExceeded = + canUseAgentHoursUsageForThisLicense && + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursHardLimit !== undefined && + agentHoursHardLimit >= agentHoursAllocation && + agentHoursDisplayActual !== undefined && + agentHoursDisplayActual >= agentHoursHardLimit; + const isAgentHoursExceeded = + canUseAgentHoursUsageForThisLicense && + !isAgentHoursHardLimitExceeded && + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursDisplayActual !== undefined && + agentHoursDisplayActual > agentHoursAllocation; + const statusClassName = - isAiGovernanceAddOnExceeded || isExpired + isAgentHoursHardLimitExceeded || + isAgentHoursExceeded || + isAiGovernanceAddOnExceeded || + isExpired ? "text-content-destructive" : isNotYetValid ? "text-content-warning" : "text-content-success"; - const statusText = isAiGovernanceAddOnExceeded - ? "Add-on exceeded" - : isExpired - ? "Expired" - : isNotYetValid - ? "Not started" - : "Active"; - const hasCollapsibleContent = isPremium && hasExplicitAiGovernanceAddOn; + const statusText = isAgentHoursHardLimitExceeded + ? "Hard limit exceeded" + : isAgentHoursExceeded + ? "Agent hours exceeded" + : isAiGovernanceAddOnExceeded + ? "Add-on exceeded" + : isExpired + ? "Expired" + : isNotYetValid + ? "Not started" + : "Active"; const headerContent = ( <>
- {hasCollapsibleContent && ( - - )} + #{license.id} @@ -122,6 +174,12 @@ export const LicenseCard: FC = ({ Status {statusText}
+
+ Type + + {license.claims.trial ? "Trial" : "Standard"} + +
Users @@ -177,23 +235,17 @@ export const LicenseCard: FC = ({ />
- {hasCollapsibleContent ? ( - + - - ) : ( -
{headerContent} -
- )} + + @@ -220,22 +272,41 @@ export const LicenseCard: FC = ({
- {hasCollapsibleContent && ( -
-
- Add-ons -
-
- +
+ Products +
+
+ + {isPremium && ( + -
+ )}
- )} + {hasExplicitAiGovernanceAddOn && ( + <> +
+ Add-ons +
+
+ +
+ + )} +
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.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index 60689401139..8030439411f 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -40,6 +40,7 @@ type Props = { activeUsers: UserStatusChangeCount[] | undefined; managedAgentFeature?: Feature; aiGovernanceUserFeature?: Feature; + agentRuntimeHoursFeature?: Feature; }; const LicensesSettingsPageView: FC = ({ @@ -56,6 +57,7 @@ const LicensesSettingsPageView: FC = ({ activeUsers, managedAgentFeature, aiGovernanceUserFeature, + agentRuntimeHoursFeature, }) => { const theme = useTheme(); const { width, height } = useWindowSize(); @@ -124,6 +126,7 @@ const LicensesSettingsPageView: FC = ({ userLimitActual={userLimitActual} userLimitLimit={userLimitLimit} aiGovernanceUserFeature={aiGovernanceUserFeature} + agentRuntimeHoursFeature={agentRuntimeHoursFeature} isRemoving={isRemovingLicense} onRemove={removeLicense} /> diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts new file mode 100644 index 00000000000..52ea45d47e3 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts @@ -0,0 +1,24 @@ +import dayjs from "dayjs"; +import type { GetLicensesResponse } from "#/api/api"; +import type { Feature } from "#/api/typesGenerated"; + +/** + * Usage and overage indicators only apply to licenses that are currently + * effective: past their nbf and not expired, unless the merged entitlement + * for the feature is in its grace period (an expired license can still be + * the one granting the feature while the grace period lasts). + */ +export function isLicenseApplicableForFeatureUsage( + license: GetLicensesResponse, + feature: Feature | undefined, +): boolean { + const isExpired = dayjs + .unix(license.claims.license_expires) + .isBefore(dayjs()); + const isNotYetValid = + license.claims.nbf !== undefined && + dayjs.unix(license.claims.nbf).isAfter(dayjs()); + const isFeatureInGracePeriod = feature?.entitlement === "grace_period"; + + return !isNotYetValid && (!isExpired || isFeatureInGracePeriod); +} From e617d4e44cff6189c35eb7083898bfba782f3601 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 05:40:08 +0000 Subject: [PATCH 2/5] chore(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): pin grandfathered agent hours rendering in the Premium story The backend now grandfathers claim-less premium licenses into a zero-hour agent runtime allocation, so the merged entitlement (and its measured usage) is always present. The Premium upgrade-state story now passes that realistic merged shape and asserts the deployment-wide usage row. --- .../LicensesSettingsPage/LicenseCard.stories.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index 11022111d89..855b7e7faa8 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -107,15 +107,27 @@ export const UsesLicenseUserLimit: Story = { export const Premium: Story = { args: { license: MockLicenseResponse[1], + // The backend grandfathers premium licenses without agent hour + // claims into a zero-hour allocation, so the merged entitlement is + // always present: disabled, zero limit, usage measured. + agentRuntimeHoursFeature: { + enabled: false, + entitlement: "entitled", + limit: 0, + actual: 137, + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // A Premium license with no agent hours allocation shows the Coder - // Agents upgrade card. + // Agents upgrade card, including deployment-wide usage. await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); await expect( getMetricValue(canvas, "Max concurrent chats"), ).toHaveTextContent("5"); + await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( + "Agent hours used: 137", + ); const upgrade = canvas.getByRole("link", { name: "Upgrade" }); await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); }, From 9c8ac85d078f29f0e45c8070a055a789667d934d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 07:05:56 +0000 Subject: [PATCH 3/5] feat(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): show agent runtime hours with one decimal Derive the license page's agent hours value from the entitlement's new actual_ms, floored to tenths of an hour with integer math so the displayed number and the exceeded state flip at the same instant as the backend's whole-hour thresholds. Usage renders with exactly one decimal (e.g. 42.0, 10.3) on both the Total Agent hours metric and the upgrade card's usage line; the allocation denominator stays whole. --- .../CoderAgentsProductCard.stories.tsx | 14 ++++--- .../CoderAgentsProductCard.tsx | 18 +++++--- .../LicenseCard.stories.tsx | 41 +++++++++++++++++-- .../LicensesSettingsPage/LicenseCard.tsx | 10 ++++- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index 0f51a52a54b..76de6dd5ddd 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -8,7 +8,9 @@ const meta: Meta = { component: CoderAgentsProductCard, args: { allocation: 20000, - actual: 16264, + // Fractional usage renders with one decimal; whole values render + // with a trailing .0 (see Exceeded). + actual: 16264.3, isExceeded: false, isHardLimitExceeded: false, }, @@ -25,7 +27,7 @@ export const Default: Story = { const canvas = within(canvasElement); await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "16,264 / 20,000", + "16,264.3 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", @@ -75,7 +77,7 @@ export const Exceeded: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "21,000 / 20,000", + "21,000.0 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", @@ -91,7 +93,7 @@ export const HardLimitExceeded: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "25,000 / 20,000", + "25,000.0 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "5", @@ -120,12 +122,12 @@ export const NoAllocation: Story = { export const NoAllocationWithUsage: Story = { args: { allocation: undefined, - actual: 1234, + actual: 1234.5, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( - "Agent hours used: 1,234", + "Agent hours used: 1,234.5", ); await expect( canvas.getByRole("link", { name: "Upgrade" }), diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index 3154054b4f3..cc8c0e0d232 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -30,8 +30,9 @@ type CoderAgentsProductCardProps = { allocation?: number; /** * Agent runtime hours used in the current usage period, from the - * merged entitlements. Undefined when usage does not apply to this - * license (another license provides the feature) or is unknown. + * merged entitlements, floored to tenths of an hour. Undefined when + * usage does not apply to this license (another license provides the + * feature) or is unknown. */ actual?: number; /** Usage is above this license's allocation. */ @@ -84,6 +85,14 @@ const totalAgentHoursTooltip = const concurrentChatsTooltip = "Number of Coder Agents chats that can run at the same time."; +// Usage always renders with exactly one decimal (e.g. 42.0, 10.3). The +// value is already floored to tenths, so no rounding happens here. +const formatHoursUsed = (hours: number) => + hours.toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + export const CoderAgentsProductCard: FC = ({ allocation, actual, @@ -109,7 +118,7 @@ export const CoderAgentsProductCard: FC = ({
Agent hours used:{" "} - {actual.toLocaleString("en-US")} + {formatHoursUsed(actual)}
)} @@ -122,8 +131,7 @@ export const CoderAgentsProductCard: FC = ({ } const isOverage = isExceeded || isHardLimitExceeded; - const actualLabel = - actual === undefined ? "\u2014" : actual.toLocaleString("en-US"); + const actualLabel = actual === undefined ? "\u2014" : formatHoursUsed(actual); return ( { @@ -126,7 +128,7 @@ export const Premium: Story = { getMetricValue(canvas, "Max concurrent chats"), ).toHaveTextContent("5"); await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( - "Agent hours used: 137", + "Agent hours used: 137.3", ); const upgrade = canvas.getByRole("link", { name: "Upgrade" }); await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); @@ -160,13 +162,15 @@ export const PremiumWithAgentHours: Story = { soft_limit: 16000, hard_limit: 25000, actual: 16264, + // 16,264 hours and 18 minutes: renders as 16,264.3. + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Active")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "16,264 / 20,000", + "16,264.3 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", @@ -190,13 +194,14 @@ export const PremiumWithAgentHoursExceeded: Story = { soft_limit: 16000, hard_limit: 25000, actual: 21000, + actual_ms: 21_000 * 3_600_000, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Agent hours exceeded")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "21,000 / 20,000", + "21,000.0 / 20,000", ); // Concurrency is only capped once the hard limit is reached. await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( @@ -215,13 +220,14 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { soft_limit: 16000, hard_limit: 25000, actual: 25000, + actual_ms: 25_000 * 3_600_000, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Hard limit exceeded")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "25,000 / 20,000", + "25,000.0 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "5", @@ -229,6 +235,31 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { }, }; +export const PremiumWithAgentHoursExceededByFraction: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + // The whole-hour actual sits exactly at the allocation, but the + // extra 6 minutes push the tenths-precision value past it, so + // the fraction alone flips the exceeded state. + actual: 20000, + actual_ms: 20_000 * 3_600_000 + 6 * 60_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Agent hours exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "20,000.1 / 20,000", + ); + }, +}; + export const PremiumWithUnlimitedAgentHours: Story = { args: { license: premiumLicenseWithAgentHours(-1), @@ -236,6 +267,7 @@ export const PremiumWithUnlimitedAgentHours: Story = { enabled: true, entitlement: "entitled", actual: 16264, + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, }, }, play: async ({ canvasElement }) => { @@ -258,6 +290,7 @@ export const LowerAgentHoursCardUsesMergedEntitlement: Story = { entitlement: "entitled", limit: 20000, actual: 16264, + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, }, }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 764717094c6..1b9c2a0cfb1 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -114,12 +114,20 @@ export const LicenseCard: FC = ({ agentHoursAllocation === agentRuntimeHoursFeature?.limit; const canUseAgentHoursUsageForThisLicense = isAgentHoursLicenseApplicable && isWinningAgentHoursLicense; + // Precise usage in tenths of hours, floored via integer math so the + // displayed number and the exceeded state below flip at the same + // instant as the backend's whole-hour warning thresholds. + const agentHoursActualMs = agentRuntimeHoursFeature?.actual_ms; + const agentHoursActual = + agentHoursActualMs === undefined + ? undefined + : Math.floor(agentHoursActualMs / 360_000) / 10; // Usage applies to the winning license's quota. Licenses without an // allocation show deployment-wide usage in their upgrade card instead. const agentHoursDisplayActual = isAgentHoursLicenseApplicable && (isWinningAgentHoursLicense || !licenseGrantsAgentHours) - ? agentRuntimeHoursFeature?.actual + ? agentHoursActual : undefined; const isAgentHoursHardLimitExceeded = canUseAgentHoursUsageForThisLicense && From ea1ef4f51a047ff8e9cd31dc0fc36be21e2a65d4 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 10:04:22 +0000 Subject: [PATCH 4/5] fix(site/src): identify winning agent hours license by merged usage period The merged entitlement's usage period is stamped with the issued-at of the license Feature.Compare selected, so comparing allocation alone could mark an older duplicate (e.g. a replaced renewal) as the winner and report its overage state. Require the license iat to match usage_period.issued_at before treating a card as the winning license. Also exercise the product card tooltips (keyboard and hover) in Storybook, drop comments that narrated assertions, and name the exact backend constant the concurrency cap mirrors. --- site/src/api/api.ts | 4 ++ .../CoderAgentsProductCard.stories.tsx | 33 +++++++++- .../CoderAgentsProductCard.tsx | 2 +- .../CoderWorkspacesProductCard.stories.tsx | 17 ++++- .../LicenseCard.stories.tsx | 66 ++++++++++++++++--- .../LicensesSettingsPage/LicenseCard.tsx | 19 ++++-- 6 files changed, 126 insertions(+), 15 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7c12690a077..0fe8286ce6d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -365,6 +365,10 @@ type Claims = { license_expires: number; // nbf is a standard JWT claim for "not before" - the license valid from date nbf?: number; + // iat is a standard JWT claim for "issued at". Valid licenses always + // carry it; the merged entitlement's usage_period.issued_at is stamped + // from the winning license's iat. + iat?: number; account_type?: string; account_id?: string; trial: boolean; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index 76de6dd5ddd..d4ec4cc8777 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, screen, waitFor, within } from "storybook/test"; import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; const meta: Meta = { @@ -42,6 +42,37 @@ export const Default: Story = { }, }; +export const TooltipInteractions: Story = { + play: async ({ canvasElement, userEvent, step }) => { + const canvas = within(canvasElement); + await step("open the Total Agent hours tooltip from keyboard", async () => { + await userEvent.tab(); + await expect( + canvas.getByRole("button", { name: "Total Agent hours information" }), + ).toHaveFocus(); + await waitFor(async () => { + await expect(screen.getByRole("tooltip")).toHaveTextContent( + "Total agent runtime hours used out of the hours included in this license.", + ); + }); + await userEvent.keyboard("{Escape}"); + await waitFor(async () => { + await expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + await step("open the Concurrent chats tooltip on hover", async () => { + await userEvent.hover( + canvas.getByRole("button", { name: "Concurrent chats information" }), + ); + await waitFor(async () => { + await expect(screen.getByRole("tooltip")).toHaveTextContent( + "Number of Coder Agents chats that can run at the same time.", + ); + }); + }); + }, +}; + export const UnlimitedAllocation: Story = { args: { allocation: -1, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index cc8c0e0d232..08abb8639dd 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -16,7 +16,7 @@ import { cn } from "#/utils/cn"; // enterprise/coderd/license). const unlimitedAllocation = -1; -// Mirrors the backend's maxConcurrentRootAgents constant, which caps +// Mirrors defaultMaxConcurrentRootAgents in coderd/x/chatd, which caps // concurrent chats once the hard limit is reached. It is not exposed via // the API, so keep this value in sync with the backend. const maxConcurrentChatsOverHardLimit = 5; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx index 805e4dde932..0bea7524b56 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, screen, waitFor, within } from "storybook/test"; import { CoderWorkspacesProductCard } from "./CoderWorkspacesProductCard"; const meta: Meta = { @@ -25,6 +25,21 @@ export const Default: Story = { }, }; +export const TooltipInteraction: Story = { + play: async ({ canvasElement, userEvent }) => { + const canvas = within(canvasElement); + await userEvent.tab(); + await expect( + canvas.getByRole("button", { name: "Active seat usage information" }), + ).toHaveFocus(); + await waitFor(async () => { + await expect(screen.getByRole("tooltip")).toHaveTextContent( + "Only Active user accounts consume license seats.", + ); + }); + }, +}; + export const UnlimitedSeats: Story = { args: { userLimitLimit: undefined, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index fe055c75597..f751a3f5830 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -30,14 +30,11 @@ export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("#1")).toBeInTheDocument(); - // The Users header field and the Coder Workspaces product card show - // the same seat usage. await expect(canvas.getAllByText("4 / 10")).toHaveLength(2); await expect(canvas.getByText("Enterprise")).toBeInTheDocument(); await expect(canvas.getByText("Standard")).toBeInTheDocument(); await expect(canvas.getByText("Products")).toBeInTheDocument(); await expect(canvas.getByText("Coder Workspaces")).toBeInTheDocument(); - // Enterprise licenses do not get the Coder Agents product. await expect(canvas.queryByText("Coder Agents")).not.toBeInTheDocument(); }, }; @@ -121,8 +118,6 @@ export const Premium: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // A Premium license with no agent hours allocation shows the Coder - // Agents upgrade card, including deployment-wide usage. await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); await expect( getMetricValue(canvas, "Max concurrent chats"), @@ -135,10 +130,24 @@ export const Premium: Story = { }, }; -const premiumLicenseWithAgentHours = (allocation: number) => ({ +// Issued-at of the license that supplies the merged entitlement. The +// merged usage period is stamped with this timestamp, so only the license +// carrying the same iat claim shows usage and overage. +const WINNING_ISSUED_AT = dayjs("2026-01-01T12:00:00Z"); +const winningUsagePeriod = { + issued_at: WINNING_ISSUED_AT.toISOString(), + start: WINNING_ISSUED_AT.toISOString(), + end: WINNING_ISSUED_AT.add(1, "year").toISOString(), +}; + +const premiumLicenseWithAgentHours = ( + allocation: number, + issuedAt = WINNING_ISSUED_AT, +) => ({ ...MockLicenseResponse[1], claims: { ...MockLicenseResponse[1].claims, + iat: issuedAt.unix(), features: { ...MockLicenseResponse[1].claims.features, agent_runtime_hours_allocation: allocation, @@ -164,6 +173,7 @@ export const PremiumWithAgentHours: Story = { actual: 16264, // 16,264 hours and 18 minutes: renders as 16,264.3. actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -195,6 +205,7 @@ export const PremiumWithAgentHoursExceeded: Story = { hard_limit: 25000, actual: 21000, actual_ms: 21_000 * 3_600_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -203,7 +214,6 @@ export const PremiumWithAgentHoursExceeded: Story = { await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "21,000.0 / 20,000", ); - // Concurrency is only capped once the hard limit is reached. await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", ); @@ -221,6 +231,7 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { hard_limit: 25000, actual: 25000, actual_ms: 25_000 * 3_600_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -249,6 +260,7 @@ export const PremiumWithAgentHoursExceededByFraction: Story = { // the fraction alone flips the exceeded state. actual: 20000, actual_ms: 20_000 * 3_600_000 + 6 * 60_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -268,6 +280,7 @@ export const PremiumWithUnlimitedAgentHours: Story = { entitlement: "entitled", actual: 16264, actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -284,13 +297,17 @@ export const PremiumWithUnlimitedAgentHours: Story = { export const LowerAgentHoursCardUsesMergedEntitlement: Story = { args: { - license: premiumLicenseWithAgentHours(10000), + license: premiumLicenseWithAgentHours( + 10000, + WINNING_ISSUED_AT.subtract(1, "year"), + ), agentRuntimeHoursFeature: { enabled: true, entitlement: "entitled", limit: 20000, actual: 16264, actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -306,6 +323,39 @@ export const LowerAgentHoursCardUsesMergedEntitlement: Story = { }, }; +export const ReplacedDuplicateAllocationShowsNoUsage: Story = { + args: { + // An older license with the same allocation as the winning renewal. + // Only the license whose iat matches the merged usage period shows + // usage, so this card stays free of usage and overage even though + // its allocation equals the merged limit. + license: premiumLicenseWithAgentHours( + 20000, + WINNING_ISSUED_AT.subtract(1, "year"), + ), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 26000, + actual_ms: 26_000 * 3_600_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + export const PremiumWithAIGovernance: Story = { args: { license: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 1b9c2a0cfb1..73590ae3349 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -103,15 +103,26 @@ export const LicenseCard: FC = ({ license, agentRuntimeHoursFeature, ); - // A license "wins" when its allocation matches the merged entitlement: - // equal limits, or an unlimited allocation with the merged limit omitted. + // The merged entitlement's usage period is stamped with the issued-at + // of the license the backend selected, so a license only "wins" when + // its own iat matches. Its allocation must also match the merged + // entitlement: equal limits, or an unlimited allocation with the + // merged limit omitted. Allocation alone is not enough because a + // renewal can carry the same allocation as the license it replaces. + const mergedUsagePeriodIssuedAt = + agentRuntimeHoursFeature?.usage_period?.issued_at; + const matchesMergedUsagePeriod = + license.claims.iat !== undefined && + mergedUsagePeriodIssuedAt !== undefined && + dayjs.unix(license.claims.iat).isSame(mergedUsagePeriodIssuedAt); const isWinningAgentHoursLicense = - agentHoursAllocation === -1 + matchesMergedUsagePeriod && + (agentHoursAllocation === -1 ? agentRuntimeHoursFeature?.enabled === true && agentRuntimeHoursFeature.limit === undefined : agentHoursAllocation !== undefined && agentHoursAllocation > 0 && - agentHoursAllocation === agentRuntimeHoursFeature?.limit; + agentHoursAllocation === agentRuntimeHoursFeature?.limit); const canUseAgentHoursUsageForThisLicense = isAgentHoursLicenseApplicable && isWinningAgentHoursLicense; // Precise usage in tenths of hours, floored via integer math so the From 9a1e2c46eb52f4b5b12547302237c50db9887781 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 10:41:14 +0000 Subject: [PATCH 5/5] fix(site/src): match full usage period and render agents card on runtime claims The backend decodes agent_runtime_hours_* claims for every license regardless of feature set, so an Enterprise license carrying an allocation now renders the Coder Agents product instead of being hidden by the Premium-only condition. Feature.Compare tie-breaks equal issued-at values on the usage period end, so the winning-license predicate now matches the complete merged usage period (iat/nbf/exp) plus the validated soft and hard thresholds, not issued-at alone. The hard-limit claim validation moved into the effective threshold computation shared by both checks. --- site/src/api/api.ts | 5 ++ .../LicenseCard.stories.tsx | 86 ++++++++++++++++++- .../LicensesSettingsPage/LicenseCard.tsx | 73 ++++++++++++---- 3 files changed, 143 insertions(+), 21 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0fe8286ce6d..65d97cea0f4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -369,6 +369,11 @@ type Claims = { // carry it; the merged entitlement's usage_period.issued_at is stamped // from the winning license's iat. iat?: number; + // exp is a standard JWT claim for "expires at": the end of the grace + // period (identical to license_expires when there is no grace period). + // The merged entitlement's usage_period.end is stamped from the + // winning license's exp, and usage_period.start from its nbf. + exp?: number; account_type?: string; account_id?: string; trial: boolean; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index f751a3f5830..d1fbf5a9697 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -131,8 +131,9 @@ export const Premium: Story = { }; // Issued-at of the license that supplies the merged entitlement. The -// merged usage period is stamped with this timestamp, so only the license -// carrying the same iat claim shows usage and overage. +// merged usage period is copied from that license's iat/nbf/exp claims, +// so only the license whose claims reproduce the whole period shows +// usage and overage. const WINNING_ISSUED_AT = dayjs("2026-01-01T12:00:00Z"); const winningUsagePeriod = { issued_at: WINNING_ISSUED_AT.toISOString(), @@ -148,6 +149,8 @@ const premiumLicenseWithAgentHours = ( claims: { ...MockLicenseResponse[1].claims, iat: issuedAt.unix(), + nbf: issuedAt.unix(), + exp: issuedAt.add(1, "year").unix(), features: { ...MockLicenseResponse[1].claims.features, agent_runtime_hours_allocation: allocation, @@ -356,6 +359,85 @@ export const ReplacedDuplicateAllocationShowsNoUsage: Story = { }, }; +const sameIssuedAtShorterTermLicense = (() => { + const license = premiumLicenseWithAgentHours(20000); + return { + ...license, + claims: { + ...license.claims, + exp: WINNING_ISSUED_AT.add(6, "month").unix(), + }, + }; +})(); + +export const SameIssuedAtDifferentTermEndShowsNoUsage: Story = { + args: { + // Same iat and allocation as the winning license, but a shorter + // term. Feature.Compare tie-breaks equal issued-at values on the + // period end, so this license loses and must not display the + // merged usage or overage. + license: sameIssuedAtShorterTermLicense, + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 26000, + actual_ms: 26_000 * 3_600_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +const enterpriseLicenseWithAgentHours = (() => { + const license = premiumLicenseWithAgentHours(20000); + return { + ...license, + claims: { + ...license.claims, + feature_set: "enterprise", + }, + }; +})(); + +export const EnterpriseWithAgentHours: Story = { + args: { + // The backend accepts runtime hour claims on any feature set, so + // an Enterprise license carrying an allocation renders the Coder + // Agents product with its usage. + license: enterpriseLicenseWithAgentHours, + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 16264, + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Enterprise")).toBeInTheDocument(); + await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264.3 / 20,000", + ); + }, +}; + export const PremiumWithAIGovernance: Story = { args: { license: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 73590ae3349..8396180c08b 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -89,34 +89,72 @@ export const LicenseCard: FC = ({ : undefined; // Agent runtime hour claims, in hours. The -1 allocation is the - // unlimited sentinel; other non-positive allocations do not grant the - // feature. The hard limit only applies to positive allocations it is - // at or above, mirroring the backend's claim validation. + // unlimited sentinel; other negative allocations are ignored by the + // backend, and a zero allocation grants the feature disabled. const agentHoursAllocation = license.claims.features.agent_runtime_hours_allocation; - const agentHoursHardLimit = - license.claims.features.agent_runtime_hours_limit_hard; + // The backend decodes runtime hour claims for every license + // regardless of feature set, so an Enterprise license carrying a + // usable allocation claim also gets the Coder Agents product. + // Premium licenses without claims are grandfathered into the + // zero-hour (upgrade) display. + const hasAgentHoursClaim = + agentHoursAllocation !== undefined && + (agentHoursAllocation >= 0 || agentHoursAllocation === -1); const licenseGrantsAgentHours = agentHoursAllocation !== undefined && (agentHoursAllocation > 0 || agentHoursAllocation === -1); + // Thresholds after the backend's claim validation: soft must be + // non-negative and below a positive allocation, hard at or above it. + // Invalid threshold claims are ignored rather than disqualifying the + // license. + const agentHoursSoftLimitClaim = + license.claims.features.agent_runtime_hours_limit_soft; + const agentHoursHardLimitClaim = + license.claims.features.agent_runtime_hours_limit_hard; + const agentHoursSoftLimit = + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursSoftLimitClaim !== undefined && + agentHoursSoftLimitClaim >= 0 && + agentHoursSoftLimitClaim < agentHoursAllocation + ? agentHoursSoftLimitClaim + : undefined; + const agentHoursHardLimit = + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursHardLimitClaim !== undefined && + agentHoursHardLimitClaim >= agentHoursAllocation + ? agentHoursHardLimitClaim + : undefined; const isAgentHoursLicenseApplicable = isLicenseApplicableForFeatureUsage( license, agentRuntimeHoursFeature, ); - // The merged entitlement's usage period is stamped with the issued-at - // of the license the backend selected, so a license only "wins" when - // its own iat matches. Its allocation must also match the merged - // entitlement: equal limits, or an unlimited allocation with the - // merged limit omitted. Allocation alone is not enough because a - // renewal can carry the same allocation as the license it replaces. - const mergedUsagePeriodIssuedAt = - agentRuntimeHoursFeature?.usage_period?.issued_at; + // The merged entitlement's usage period is copied verbatim from the + // license the backend selected (issued_at from iat, start from nbf, + // end from exp), so a license only "wins" when all three match. + // Feature.Compare tie-breaks equal issued-at values on the period + // end, so matching issued-at alone could mark two licenses with the + // same second-granularity iat as the winner. + const mergedUsagePeriod = agentRuntimeHoursFeature?.usage_period; const matchesMergedUsagePeriod = license.claims.iat !== undefined && - mergedUsagePeriodIssuedAt !== undefined && - dayjs.unix(license.claims.iat).isSame(mergedUsagePeriodIssuedAt); + license.claims.nbf !== undefined && + license.claims.exp !== undefined && + mergedUsagePeriod !== undefined && + dayjs.unix(license.claims.iat).isSame(mergedUsagePeriod.issued_at) && + dayjs.unix(license.claims.nbf).isSame(mergedUsagePeriod.start) && + dayjs.unix(license.claims.exp).isSame(mergedUsagePeriod.end); + // Beyond the usage period, the license's allocation and validated + // thresholds must equal the merged entitlement's: equal limits (or an + // unlimited allocation with the merged limit omitted) and equal + // soft/hard thresholds, since the backend retains only the selected + // license's thresholds. const isWinningAgentHoursLicense = matchesMergedUsagePeriod && + agentHoursSoftLimit === agentRuntimeHoursFeature?.soft_limit && + agentHoursHardLimit === agentRuntimeHoursFeature?.hard_limit && (agentHoursAllocation === -1 ? agentRuntimeHoursFeature?.enabled === true && agentRuntimeHoursFeature.limit === undefined @@ -142,10 +180,7 @@ export const LicenseCard: FC = ({ : undefined; const isAgentHoursHardLimitExceeded = canUseAgentHoursUsageForThisLicense && - agentHoursAllocation !== undefined && - agentHoursAllocation > 0 && agentHoursHardLimit !== undefined && - agentHoursHardLimit >= agentHoursAllocation && agentHoursDisplayActual !== undefined && agentHoursDisplayActual >= agentHoursHardLimit; const isAgentHoursExceeded = @@ -300,7 +335,7 @@ export const LicenseCard: FC = ({ userLimitActual={userLimitActual} userLimitLimit={currentUserLimit} /> - {isPremium && ( + {(isPremium || hasAgentHoursClaim) && (