From 995d7fe31bb30713c765a302a1a8a71b86ae5f15 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 18 Aug 2026 20:07:28 +0700 Subject: [PATCH] feat: add per-license Products section with Coder Agents price gates (#28051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2026-08-17 at 3 43 51 PM Each license card now always expands to a **Products** section: a Coder Workspaces box showing active seat usage, and, on Premium licenses, a Coder Agents box driven by the `agent_runtime_hours_*` license claims and the merged `agent_runtime_hours` entitlement. The card header gains a **Type** column (`Trial`/`Standard`), and the left header label now shows the feature set only (`Premium`/`Enterprise`). The Coder Agents box renders five states: no allocation (dashed purple upgrade CTA), unlimited allocation (`-1` sentinel), normal usage, allocation exceeded (red border and red "Agent hours exceeded" status; concurrent chats stay Unlimited), and hard limit exceeded (red "Hard limit exceeded" status; concurrent chats capped at 5, mirroring the backend's `maxConcurrentRootAgents`, which is not exposed via the API). Usage and overage indicators only render on the license whose allocation matches the merged entitlement and which is currently effective, following the existing AI Governance winning-license pattern via a generalized `isLicenseApplicableForFeatureUsage` helper; AI Governance add-on behavior is unchanged. Stacked on #27985 (base branch `runtime-hours-entitlements`); do not merge before it. Notes for review: - #27985 now grandfathers claim-less Premium licenses into a zero-hour `agent_runtime_hours` allocation, so the merged entitlement (disabled, `limit: 0`) and its measured `actual` are always present for Premium deployments. The upgrade card's "Agent hours used" row therefore renders universally; the `Premium` story pins that state. - Agent hours usage now renders with exactly one decimal (e.g. `16,264.3`, `42.0`), derived from #27985's new `actual_ms` field and floored to tenths with integer math. The same floored value drives the exceeded checks, so the displayed number and the red state flip at the same instant; a fraction past the allocation now trips "Agent hours exceeded" (`20,000.1 > 20,000`), pinned by the `PremiumWithAgentHoursExceededByFraction` story. The allocation denominator stays whole (it comes from the whole-hour license claim). --- site/src/api/api.ts | 6 + .../AIGovernanceLicensing.ts | 30 +- .../CoderAgentsProductCard.stories.tsx | 195 +++++++ .../CoderAgentsProductCard.tsx | 230 ++++++++ .../CoderWorkspacesProductCard.stories.tsx | 78 +++ .../CoderWorkspacesProductCard.tsx | 55 ++ .../LicenseCard.stories.tsx | 510 ++++++++++++++++-- .../LicensesSettingsPage/LicenseCard.tsx | 268 +++++++-- .../LicensesSettingsPage.tsx | 3 + .../LicensesSettingsPageView.tsx | 3 + .../licenseApplicability.ts | 22 + 11 files changed, 1285 insertions(+), 115 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/api/api.ts b/site/src/api/api.ts index 7c12690a07..e870ba8d61 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -365,6 +365,12 @@ 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"; the merged + // usage_period.issued_at is stamped from the winning license's iat. + iat?: number; + // exp is a standard JWT claim for "expires at" (end of grace period); + // it stamps usage_period.end, and nbf stamps usage_period.start. + exp?: number; account_type?: string; account_id?: string; trial: boolean; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts index cbae3e582a..bfe1eaa253 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 0000000000..51c13d33b4 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -0,0 +1,195 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard", + component: CoderAgentsProductCard, + args: { + allocation: 20000, + actual: 16264.3, + isSoftLimitReached: false, + 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.3 / 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 TooltipInteractions: Story = { + play: async ({ canvasElement, 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, + }, + 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 SoftLimitReached: Story = { + args: { + actual: 16264.3, + isSoftLimitReached: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264.3 / 20,000", + ); + await expect(canvas.getByRole("status")).toHaveTextContent( + "Approaching hours limit", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +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.0 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + await expect(canvas.queryByRole("status")).not.toBeInTheDocument(); + }, +}; + +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.0 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); + await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached"); + 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. You've reached your limit: concurrent chats are now capped at 5 (down from unlimited).", + ); + }); + }, +}; + +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.5, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Agent hours used")).toHaveTextContent( + "1,234.5", + ); + 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 0000000000..f2db2d3c98 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -0,0 +1,230 @@ +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { Link as RouterLink } from "react-router"; +import { Badge } from "#/components/Badge/Badge"; +import { Button } from "#/components/Button/Button"; +import { Link } from "#/components/Link/Link"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { cn } from "#/utils/cn"; + +// Allocation sentinel for unlimited agent runtime hours +// (AgentRuntimeHoursUnlimitedAllocation in enterprise/coderd/license). +const unlimitedAllocation = -1; + +// Concurrent chat cap once the hard limit is reached. Mirrors +// defaultMaxConcurrentRootAgents in coderd/x/chatd; keep in sync. +const maxConcurrentChatsOverHardLimit = 5; + +type CoderAgentsProductCardProps = { + /** + * The license's agent_runtime_hours_allocation claim, in hours. + * Undefined or non-positive (except -1, unlimited) grants no hours. + */ + allocation?: number; + /** + * Hours used in the current usage period, floored to tenths. + * Undefined when usage does not apply to this license. + */ + actual?: number; + /** + * Usage is at or above this license's advisory soft limit, but still + * within the purchased allocation. + */ + isSoftLimitReached: boolean; + /** 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; + headerEnd?: ReactNode; + children: ReactNode; +}> = ({ className, headerEnd, children }) => ( +
+
+
+ Coder Agents +
+ {headerEnd} +
+ {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."; +const concurrentChatsHardLimitTooltip = `${concurrentChatsTooltip} You've reached your limit: concurrent chats are now capped at ${maxConcurrentChatsOverHardLimit} (down from unlimited).`; + +// 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, + isSoftLimitReached, + isExceeded, + isHardLimitExceeded, +}) => { + const isUnlimited = allocation === unlimitedAllocation; + const grantsAgentHours = + allocation !== undefined && (allocation > 0 || isUnlimited); + + if (!grantsAgentHours) { + return ( + +
+
+ +
+ {maxConcurrentChatsOverHardLimit} +
+
+ {actual !== undefined && ( +
+
+ Agent hours used +
+
+ {formatHoursUsed(actual)} +
+
+ )} +
+ +
+ ); + } + + const isOverage = isExceeded || isHardLimitExceeded; + const actualLabel = actual === undefined ? "\u2014" : formatHoursUsed(actual); + const hoursValueClassName = isOverage + ? "text-content-destructive" + : isSoftLimitReached + ? "text-border-warning" + : undefined; + + return ( + + + Limit reached + + ) : isSoftLimitReached && !isOverage ? ( + // The soft limit is otherwise only conveyed by the warning + // colors, so announce it for assistive technology too. + + Approaching hours limit + + ) : undefined + } + > +
+
+ +
+ {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 0000000000..b264963372 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor, 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 TooltipInteraction: Story = { + play: async ({ canvasElement }) => { + 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, + }, + 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 0000000000..d8341b1832 --- /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 88cc7a3208..9b19c823e7 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, userEvent, waitFor, within } from "storybook/test"; import { MockLicenseResponse } from "#/testHelpers/entities"; import { LicenseCard } from "./LicenseCard"; @@ -23,12 +23,67 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const getMetricValue = (canvas: ReturnType, label: string) => + canvas.getByText(label).parentElement?.nextElementSibling; + +const getIncludedProducts = ( + canvas: ReturnType, + label: string, +) => + canvas.queryByRole("group", { + name: (accessibleName: string) => accessibleName === label, + }); + export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("#1")).toBeInTheDocument(); - await expect(canvas.getByText("4 / 10")).toBeInTheDocument(); + 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(); + await expect(canvas.queryByText("Coder Agents")).not.toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces"), + ).not.toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).not.toBeInTheDocument(); + }, +}; + +export const CollapsesProducts: Story = { + play: async ({ canvasElement }) => { + 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"); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).toBeInTheDocument(); }, }; @@ -38,7 +93,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,13 +114,426 @@ 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); }, }; export const Premium: Story = { args: { - license: MockLicenseResponse[1], + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + // A seat-limit claim without addons is not enough to show + // the AI Governance add-on; that requires addons: ["ai_governance"]. + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 100, + limit: 1000, + }, + // Premium licenses without agent hour claims are grandfathered + // into a zero-hour allocation, so the merged entitlement exists. + agentRuntimeHoursFeature: { + enabled: false, + entitlement: "entitled", + limit: 0, + actual: 137, + // 137 hours and 18 minutes: renders as 137.3. + actual_ms: 137 * 3_600_000 + 18 * 60_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect(getIncludedProducts(canvas, "Workspaces")).toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).not.toBeInTheDocument(); + await expect( + getMetricValue(canvas, "Max concurrent chats"), + ).toHaveTextContent("5"); + await expect(getMetricValue(canvas, "Agent hours used")).toHaveTextContent( + "137.3", + ); + const upgrade = canvas.getByRole("link", { name: "Upgrade" }); + await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); + await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument(); + await expect(canvas.queryByText("AI Governance")).not.toBeInTheDocument(); + }, +}; + +// Issued-at of the license supplying the merged entitlement; only the +// license whose iat/nbf/exp reproduce the merged period shows usage. +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(), + nbf: issuedAt.unix(), + exp: issuedAt.add(1, "year").unix(), + 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: 12264, + // 12,264 hours and 18 minutes: renders as 12,264.3, below + // the 16,000-hour advisory soft limit. + actual_ms: 12_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "12,264.3 / 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 PremiumWithAgentHoursSoftLimitReached: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 16264, + // 16,264 hours and 18 minutes: renders as 16,264.3, at or + // above the 16,000-hour advisory soft limit. + actual_ms: 16_264 * 3_600_000 + 18 * 60_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( + "16,264.3 / 20,000", + ); + await expect(canvas.getByRole("status")).toHaveTextContent( + "Approaching hours limit", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const PremiumWithAgentHoursExceeded: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 21000, + actual_ms: 21_000 * 3_600_000, + usage_period: winningUsagePeriod, + }, + }, + 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.0 / 20,000", + ); + 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, + actual_ms: 25_000 * 3_600_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Limit exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "25,000.0 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); + await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached"); + }, +}; + +export const PremiumWithAgentHoursAtAllocation: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + // Usage equal to the allocation is already over: the backend + // reports the allocation as reached at this exact boundary. + actual: 20000, + actual_ms: 20_000 * 3_600_000, + usage_period: winningUsagePeriod, + }, + }, + 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.0 / 20,000", + ); + }, +}; + +export const PremiumWithAgentHoursExceededByFraction: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + // The extra 6 minutes render as a tenth past the allocation, + // so the display shows fractional overage. + actual: 20000, + actual_ms: 20_000 * 3_600_000 + 6 * 60_000, + usage_period: winningUsagePeriod, + }, + }, + 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), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + 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("Active")).toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).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, + 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 }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 10,000", + ); + await expect( + canvas.queryByText("Agent hours exceeded"), + ).not.toBeInTheDocument(); + }, +}; + +export const ReplacedDuplicateAllocationShowsNoUsage: Story = { + args: { + // Same allocation as the winning renewal but an older usage + // period, so the merged usage does not belong to this license. + 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", + ); + }, +}; + +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; the backend tie-breaks equal issued-at on the period end. + 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: { + // Runtime hour claims apply to any feature set, so an Enterprise + // license with an allocation renders the Coder Agents product. + license: enterpriseLicenseWithAgentHours, + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 12264, + actual_ms: 12_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( + getIncludedProducts(canvas, "Workspaces"), + ).not.toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).not.toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "12,264.3 / 20,000", + ); }, }; @@ -92,39 +560,17 @@ export const PremiumWithAIGovernance: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument(); - await expect(canvas.getByText(/ai governance/i)).toBeInTheDocument(); + // Matches both the included-products line and the add-on card title. + await expect(canvas.getAllByText(/ai governance/i)).toHaveLength(2); + await expect( + getIncludedProducts(canvas, "Workspaces + AI Governance"), + ).toBeInTheDocument(); const seatsLabel = canvas.getByText("Seats"); const seatsValue = seatsLabel.nextElementSibling; await expect(seatsValue).toHaveTextContent("750 / 1,000"); }, }; -export const PremiumWithoutAIGovernanceAddOn: Story = { - args: { - license: { - ...MockLicenseResponse[1], - claims: { - ...MockLicenseResponse[1].claims, - features: { - ...MockLicenseResponse[1].claims.features, - ai_governance_user_limit: 1000, - }, - }, - }, - aiGovernanceUserFeature: { - enabled: true, - entitlement: "entitled", - actual: 100, - limit: 1000, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument(); - await expect(canvas.queryByText("AI Governance")).not.toBeInTheDocument(); - }, -}; - export const Expired: Story = { args: { license: MockLicenseResponse[3], diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index b4667e4c9a..994f7ea483 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,32 +87,170 @@ export const LicenseCard: FC = ({ const aiGovernanceDisplayActual = canUseAiGovernanceUsageForThisLicense ? aiGovernanceActual : undefined; + + // Agent runtime hour claims, in hours. -1 means unlimited; other + // negatives are ignored and zero grants the feature disabled. + const agentHoursAllocation = + license.claims.features.agent_runtime_hours_allocation; + // The backend decodes these claims for any feature set, so a + // non-Premium license with a usable claim also shows Coder Agents. + const hasAgentHoursClaim = + agentHoursAllocation !== undefined && + (agentHoursAllocation >= 0 || agentHoursAllocation === -1); + const licenseGrantsAgentHours = + agentHoursAllocation !== undefined && + (agentHoursAllocation > 0 || agentHoursAllocation === -1); + // Mirror the backend's threshold validation; invalid 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 usage period is copied from the winning license's + // iat/nbf/exp claims. All three must match: issued-at alone can + // collide across licenses. + const mergedUsagePeriod = agentRuntimeHoursFeature?.usage_period; + const matchesMergedUsagePeriod = + license.claims.iat !== undefined && + 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); + // The winner's allocation and thresholds must also equal the merged + // entitlement's; an unlimited allocation reports no merged limit. + const isWinningAgentHoursLicense = + matchesMergedUsagePeriod && + agentHoursSoftLimit === agentRuntimeHoursFeature?.soft_limit && + agentHoursHardLimit === agentRuntimeHoursFeature?.hard_limit && + (agentHoursAllocation === -1 + ? agentRuntimeHoursFeature?.enabled === true && + agentRuntimeHoursFeature.limit === undefined + : agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursAllocation === agentRuntimeHoursFeature?.limit); + const canUseAgentHoursUsageForThisLicense = + isAgentHoursLicenseApplicable && isWinningAgentHoursLicense; + // Usage floored to tenths of an hour via integer math so the display + // and the exceeded states below flip at the same instant. + const agentHoursActualMs = agentRuntimeHoursFeature?.actual_ms; + const agentHoursActual = + agentHoursActualMs === undefined + ? undefined + : Math.floor(agentHoursActualMs / 360_000) / 10; + // Licenses without an allocation show deployment-wide usage in their + // upgrade card. + const agentHoursDisplayActual = + isAgentHoursLicenseApplicable && + (isWinningAgentHoursLicense || !licenseGrantsAgentHours) + ? agentHoursActual + : undefined; + const isAgentHoursHardLimitExceeded = + canUseAgentHoursUsageForThisLicense && + agentHoursHardLimit !== undefined && + agentHoursDisplayActual !== undefined && + agentHoursDisplayActual >= agentHoursHardLimit; + // Inclusive: usage equal to the allocation is already over, matching + // the backend's "allocation reached" warning boundary. + const isAgentHoursExceeded = + canUseAgentHoursUsageForThisLicense && + !isAgentHoursHardLimitExceeded && + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursDisplayActual !== undefined && + agentHoursDisplayActual >= agentHoursAllocation; + // Advisory only: at or above the soft threshold, still inside the + // purchased allocation. Allocation and hard-limit overage supersede + // this so the product card never stacks warning on destructive. + const isAgentHoursSoftLimitReached = + canUseAgentHoursUsageForThisLicense && + !isAgentHoursHardLimitExceeded && + !isAgentHoursExceeded && + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursSoftLimit !== undefined && + agentHoursDisplayActual !== undefined && + agentHoursDisplayActual >= agentHoursSoftLimit && + 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 + ? "Limit exceeded" + : isAgentHoursExceeded + ? "Agent hours exceeded" + : isAiGovernanceAddOnExceeded + ? "Add-on exceeded" + : isExpired + ? "Expired" + : isNotYetValid + ? "Not started" + : "Active"; + const includesAgents = + Boolean(license.claims.trial) || licenseGrantsAgentHours; + const includedProducts = isPremium + ? [ + "Workspaces", + ...(hasExplicitAiGovernanceAddOn ? ["AI Governance"] : []), + ...(includesAgents ? ["Agents"] : []), + ] + : []; + const includedProductsLabel = includedProducts.join(" + "); const headerContent = ( <> -
- {hasCollapsibleContent && ( - - )} +
+ #{license.id} - - {licenseType} - +
+ + {licenseType} + + {includedProducts.length > 0 && ( +
+ {includedProducts.map((product, index) => ( + + {index > 0 && ( + + + )} + {product} + + ))} +
+ )} +
@@ -122,6 +258,12 @@ export const LicenseCard: FC = ({ Status {statusText}
+
+ Type + + {license.claims.trial ? "Trial" : "Standard"} + +
Users @@ -177,23 +319,17 @@ export const LicenseCard: FC = ({ />
- {hasCollapsibleContent ? ( - + - - ) : ( -
{headerContent} -
- )} + + @@ -220,22 +356,42 @@ export const LicenseCard: FC = ({
- {hasCollapsibleContent && ( -
-
- Add-ons -
-
- -
+
+
+ Products
- )} +
+ + {(isPremium || hasAgentHoursClaim) && ( + + )} +
+ {hasExplicitAiGovernanceAddOn && ( + <> +
+ Add-ons +
+
+ +
+ + )} +
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index 9e0735314e..56f6aeec59 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 582e250de9..5a46201f2e 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 0000000000..6a419cd778 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts @@ -0,0 +1,22 @@ +import dayjs from "dayjs"; +import type { GetLicensesResponse } from "#/api/api"; +import type { Feature } from "#/api/typesGenerated"; + +/** + * A license is applicable when past its nbf and not expired, or when the + * feature is in its grace period (an expired license can still grant it). + */ +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); +}