diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7c12690a077..65d97cea0f4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -365,6 +365,15 @@ 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; + // 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/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..d4ec4cc8777 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, waitFor, within } from "storybook/test"; +import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard", + component: CoderAgentsProductCard, + args: { + allocation: 20000, + // Fractional usage renders with one decimal; whole values render + // with a trailing .0 (see Exceeded). + actual: 16264.3, + 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, 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, + }, + 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.0 / 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.0 / 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.5, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( + "Agent hours used: 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 00000000000..08abb8639dd --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -0,0 +1,185 @@ +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 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; + +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, 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. */ + 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."; + +// 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, + 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); + + 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..0bea7524b56 --- /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, 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, 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, + }, + 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..d1fbf5a9697 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,50 @@ 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(); + 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(); + }, +}; + +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 +76,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 +97,344 @@ 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], + // 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, + // 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( + getMetricValue(canvas, "Max concurrent chats"), + ).toHaveTextContent("5"); + await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( + "Agent hours used: 137.3", + ); + const upgrade = canvas.getByRole("link", { name: "Upgrade" }); + await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); + }, +}; + +// Issued-at of the license that supplies the merged entitlement. The +// 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(), + 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: 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 }) => { + 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(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, + 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("Hard limit exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "25,000.0 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); + }, +}; + +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, + 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(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); + // 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 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", + ); + }, +}; + +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", + ); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index b4667e4c9a4..8396180c08b 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,134 @@ export const LicenseCard: FC = ({ const aiGovernanceDisplayActual = canUseAiGovernanceUsageForThisLicense ? aiGovernanceActual : undefined; + + // Agent runtime hour claims, in hours. The -1 allocation is the + // 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; + // 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 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 && + 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 + : agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + 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) + ? agentHoursActual + : undefined; + const isAgentHoursHardLimitExceeded = + canUseAgentHoursUsageForThisLicense && + agentHoursHardLimit !== undefined && + 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 +228,12 @@ export const LicenseCard: FC = ({ Status {statusText}
+
+ Type + + {license.claims.trial ? "Trial" : "Standard"} + +
Users @@ -177,23 +289,17 @@ export const LicenseCard: FC = ({ />
- {hasCollapsibleContent ? ( - + - - ) : ( -
{headerContent} -
- )} + + @@ -220,22 +326,41 @@ 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 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); +}