diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7c12690a077..e870ba8d616 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 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..51c13d33b40 --- /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 00000000000..f2db2d3c98a --- /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 00000000000..b2649633729 --- /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 00000000000..d8341b18325 --- /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..9b19c823e75 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,47 +114,430 @@ 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], - }, -}; - -export const PremiumWithAIGovernance: Story = { args: { 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, }, - addons: ["ai_governance"], }, }, aiGovernanceUserFeature: { enabled: true, entitlement: "entitled", - actual: 750, + 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(/add-ons/i)).toBeInTheDocument(); - await expect(canvas.getByText(/ai governance/i)).toBeInTheDocument(); - const seatsLabel = canvas.getByText("Seats"); - const seatsValue = seatsLabel.nextElementSibling; - await expect(seatsValue).toHaveTextContent("750 / 1,000"); + 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 PremiumWithoutAIGovernanceAddOn: Story = { +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", + ); + }, +}; + +export const PremiumWithAIGovernance: Story = { args: { license: { ...MockLicenseResponse[1], @@ -109,19 +547,27 @@ export const PremiumWithoutAIGovernanceAddOn: Story = { ...MockLicenseResponse[1].claims.features, ai_governance_user_limit: 1000, }, + addons: ["ai_governance"], }, }, aiGovernanceUserFeature: { enabled: true, entitlement: "entitled", - actual: 100, + actual: 750, 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(); + await expect(canvas.getByText(/add-ons/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"); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index b4667e4c9a4..994f7ea4832 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 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 582e250de99..5a46201f2e6 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..6a419cd7788 --- /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); +}