mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
feat: add per-license Products section with Coder Agents price gates (#28051)
<img width="1100" height="312" alt="Screenshot 2026-08-17 at 3 43 51 PM" src="https://github.com/user-attachments/assets/30d21467-93ec-4880-a430-ccd4b494b8f5" /> 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).
This commit is contained in:
@@ -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;
|
||||
|
||||
+3
-27
@@ -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);
|
||||
|
||||
+195
@@ -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<typeof CoderAgentsProductCard> = {
|
||||
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<typeof CoderAgentsProductCard>;
|
||||
|
||||
const getMetricValue = (canvas: ReturnType<typeof within>, 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();
|
||||
},
|
||||
};
|
||||
+230
@@ -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,
|
||||
}) => (
|
||||
<div className="flex items-center gap-1 font-medium text-content-secondary">
|
||||
<span>{label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label} information`}
|
||||
className="m-0 inline-flex appearance-none border-0 bg-transparent p-0 text-content-secondary"
|
||||
>
|
||||
<InfoIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CardContainer: FC<{
|
||||
className?: string;
|
||||
headerEnd?: ReactNode;
|
||||
children: ReactNode;
|
||||
}> = ({ className, headerEnd, children }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-[320px] flex-1 rounded-sm border px-6 py-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-content-primary">
|
||||
Coder Agents
|
||||
</div>
|
||||
{headerEnd}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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<CoderAgentsProductCardProps> = ({
|
||||
allocation,
|
||||
actual,
|
||||
isSoftLimitReached,
|
||||
isExceeded,
|
||||
isHardLimitExceeded,
|
||||
}) => {
|
||||
const isUnlimited = allocation === unlimitedAllocation;
|
||||
const grantsAgentHours =
|
||||
allocation !== undefined && (allocation > 0 || isUnlimited);
|
||||
|
||||
if (!grantsAgentHours) {
|
||||
return (
|
||||
<CardContainer className="border-dashed border-highlight-purple">
|
||||
<div className="mt-3 flex flex-wrap gap-x-12 gap-y-3 text-xs">
|
||||
<div>
|
||||
<MetricLabel
|
||||
label="Max concurrent chats"
|
||||
tooltip={concurrentChatsTooltip}
|
||||
/>
|
||||
<div className="mt-0.5 text-sm font-medium text-content-primary">
|
||||
{maxConcurrentChatsOverHardLimit}
|
||||
</div>
|
||||
</div>
|
||||
{actual !== undefined && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 font-medium text-content-secondary">
|
||||
<span>Agent hours used</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-medium text-content-primary">
|
||||
{formatHoursUsed(actual)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild className="mt-4 w-full">
|
||||
<a href="mailto:sales@coder.com">Upgrade</a>
|
||||
</Button>
|
||||
</CardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const isOverage = isExceeded || isHardLimitExceeded;
|
||||
const actualLabel = actual === undefined ? "\u2014" : formatHoursUsed(actual);
|
||||
const hoursValueClassName = isOverage
|
||||
? "text-content-destructive"
|
||||
: isSoftLimitReached
|
||||
? "text-border-warning"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<CardContainer
|
||||
className={cn(
|
||||
"border-solid",
|
||||
isOverage
|
||||
? "border-border-destructive"
|
||||
: isSoftLimitReached
|
||||
? "border-border-warning"
|
||||
: "border-border",
|
||||
)}
|
||||
headerEnd={
|
||||
isHardLimitExceeded ? (
|
||||
<Badge variant="destructive" size="sm" role="status">
|
||||
<TriangleAlertIcon />
|
||||
Limit reached
|
||||
</Badge>
|
||||
) : isSoftLimitReached && !isOverage ? (
|
||||
// The soft limit is otherwise only conveyed by the warning
|
||||
// colors, so announce it for assistive technology too.
|
||||
<span role="status" className="sr-only">
|
||||
Approaching hours limit
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="mt-3 flex flex-wrap gap-x-12 gap-y-3 text-xs">
|
||||
<div>
|
||||
<MetricLabel
|
||||
label="Total Agent hours"
|
||||
tooltip={totalAgentHoursTooltip}
|
||||
/>
|
||||
<div className="mt-0.5 text-sm font-medium text-content-primary">
|
||||
{isUnlimited ? (
|
||||
"Unlimited"
|
||||
) : (
|
||||
<>
|
||||
<span className={hoursValueClassName}>{actualLabel}</span> /{" "}
|
||||
{allocation.toLocaleString("en-US")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<MetricLabel
|
||||
label="Concurrent chats"
|
||||
tooltip={
|
||||
isHardLimitExceeded
|
||||
? concurrentChatsHardLimitTooltip
|
||||
: concurrentChatsTooltip
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 text-sm font-medium",
|
||||
isHardLimitExceeded
|
||||
? "text-content-destructive"
|
||||
: "text-content-primary",
|
||||
)}
|
||||
>
|
||||
{isHardLimitExceeded
|
||||
? maxConcurrentChatsOverHardLimit
|
||||
: "Unlimited"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm">
|
||||
<Link asChild size="lg" showExternalIcon={false}>
|
||||
<RouterLink to="/deployment/groups">Manage usage</RouterLink>
|
||||
</Link>
|
||||
<span className="text-content-secondary" aria-hidden>
|
||||
|
|
||||
</span>
|
||||
<Link asChild size="lg" showExternalIcon={false}>
|
||||
<RouterLink to="/ai/settings/coder-agents">Agent settings</RouterLink>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContainer>
|
||||
);
|
||||
};
|
||||
+78
@@ -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<typeof CoderWorkspacesProductCard> = {
|
||||
title:
|
||||
"pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard",
|
||||
component: CoderWorkspacesProductCard,
|
||||
args: {
|
||||
userLimitActual: 4,
|
||||
userLimitLimit: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CoderWorkspacesProductCard>;
|
||||
|
||||
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");
|
||||
},
|
||||
};
|
||||
+55
@@ -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 (
|
||||
<div className="min-w-[320px] flex-1 rounded-sm border border-solid border-border px-6 py-4">
|
||||
<div className="text-sm font-medium text-content-primary">
|
||||
Coder Workspaces
|
||||
</div>
|
||||
<div className="mt-3 text-xs">
|
||||
<div className="flex items-center gap-1 font-medium text-content-secondary">
|
||||
<span>Active seat usage</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Active seat usage information"
|
||||
className="m-0 inline-flex appearance-none border-0 bg-transparent p-0 text-content-secondary"
|
||||
>
|
||||
<InfoIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
Only Active user accounts consume license seats. Dormant and
|
||||
suspended accounts don't count toward the total.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-medium text-content-primary">
|
||||
{actualLabel} / {limitLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+478
-32
@@ -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<typeof LicenseCard> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LicenseCard>;
|
||||
|
||||
const getMetricValue = (canvas: ReturnType<typeof within>, label: string) =>
|
||||
canvas.getByText(label).parentElement?.nextElementSibling;
|
||||
|
||||
const getIncludedProducts = (
|
||||
canvas: ReturnType<typeof within>,
|
||||
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],
|
||||
|
||||
@@ -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<LicenseCardProps> = ({
|
||||
license,
|
||||
aiGovernanceUserFeature,
|
||||
agentRuntimeHoursFeature,
|
||||
userLimitActual,
|
||||
userLimitLimit,
|
||||
onRemove,
|
||||
@@ -59,15 +61,11 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
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<LicenseCardProps> = ({
|
||||
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 = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasCollapsibleContent && (
|
||||
<ChevronDownIcon className="license-chevron size-4 text-content-secondary transition-colors transition-transform group-hover:text-content-primary" />
|
||||
)}
|
||||
<div className="flex items-start gap-1.5">
|
||||
<ChevronDownIcon className="license-chevron mt-1 size-4 shrink-0 text-content-secondary transition-colors transition-transform group-hover:text-content-primary" />
|
||||
<span className="text-base font-medium text-content-secondary">
|
||||
#{license.id}
|
||||
</span>
|
||||
<span className="account-type text-base font-medium text-content-primary capitalize">
|
||||
{licenseType}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="account-type text-base font-medium text-content-primary capitalize">
|
||||
{licenseType}
|
||||
</span>
|
||||
{includedProducts.length > 0 && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={includedProductsLabel}
|
||||
className="text-xs font-medium text-content-secondary"
|
||||
>
|
||||
{includedProducts.map((product, index) => (
|
||||
<span key={product}>
|
||||
{index > 0 && (
|
||||
<span className="text-highlight-purple"> + </span>
|
||||
)}
|
||||
{product}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-12 text-xs font-medium">
|
||||
@@ -122,6 +258,12 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
<span className="text-content-secondary">Status</span>
|
||||
<span className={statusClassName}>{statusText}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Type</span>
|
||||
<span className="license-type text-content-primary">
|
||||
{license.claims.trial ? "Trial" : "Standard"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Users</span>
|
||||
<span className="text-content-primary user-limit">
|
||||
@@ -177,23 +319,17 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
/>
|
||||
<div className="license-card group overflow-hidden rounded-md border border-solid border-border bg-surface-secondary text-sm shadow-sm">
|
||||
<div className="flex items-center gap-6 p-3">
|
||||
{hasCollapsibleContent ? (
|
||||
<CollapsibleTrigger
|
||||
asChild
|
||||
className="[&[data-state=closed]_.license-chevron]:-rotate-90"
|
||||
<CollapsibleTrigger
|
||||
asChild
|
||||
className="[&[data-state=closed]_.license-chevron]:-rotate-90"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="m-0 flex min-w-0 flex-1 appearance-none items-center gap-6 border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="m-0 flex min-w-0 flex-1 appearance-none items-center gap-6 border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
{headerContent}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<div className="m-0 flex min-w-0 flex-1 items-center gap-6">
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -220,22 +356,42 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
{hasCollapsibleContent && (
|
||||
<div className="border-0 border-t border-solid border-border bg-surface-primary px-4 py-4">
|
||||
<div className="text-sm font-medium text-content-secondary">
|
||||
Add-ons
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<AIGovernanceAddOnCard
|
||||
title="AI Governance"
|
||||
unit="Seats"
|
||||
actual={aiGovernanceDisplayActual}
|
||||
limit={aiGovernanceLimit}
|
||||
isExceeded={isAiGovernanceAddOnExceeded}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-0 border-t border-solid border-border bg-surface-primary px-4 py-4">
|
||||
<div className="text-sm font-medium text-content-secondary">
|
||||
Products
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<CoderWorkspacesProductCard
|
||||
userLimitActual={userLimitActual}
|
||||
userLimitLimit={currentUserLimit}
|
||||
/>
|
||||
{(isPremium || hasAgentHoursClaim) && (
|
||||
<CoderAgentsProductCard
|
||||
allocation={agentHoursAllocation}
|
||||
actual={agentHoursDisplayActual}
|
||||
isSoftLimitReached={isAgentHoursSoftLimitReached}
|
||||
isExceeded={isAgentHoursExceeded}
|
||||
isHardLimitExceeded={isAgentHoursHardLimitExceeded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasExplicitAiGovernanceAddOn && (
|
||||
<>
|
||||
<div className="mt-4 text-sm font-medium text-content-secondary">
|
||||
Add-ons
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<AIGovernanceAddOnCard
|
||||
title="AI Governance"
|
||||
unit="Seats"
|
||||
actual={aiGovernanceDisplayActual}
|
||||
limit={aiGovernanceLimit}
|
||||
isExceeded={isAiGovernanceAddOnExceeded}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
@@ -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();
|
||||
|
||||
+3
@@ -40,6 +40,7 @@ type Props = {
|
||||
activeUsers: UserStatusChangeCount[] | undefined;
|
||||
managedAgentFeature?: Feature;
|
||||
aiGovernanceUserFeature?: Feature;
|
||||
agentRuntimeHoursFeature?: Feature;
|
||||
};
|
||||
|
||||
const LicensesSettingsPageView: FC<Props> = ({
|
||||
@@ -56,6 +57,7 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
activeUsers,
|
||||
managedAgentFeature,
|
||||
aiGovernanceUserFeature,
|
||||
agentRuntimeHoursFeature,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { width, height } = useWindowSize();
|
||||
@@ -124,6 +126,7 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
userLimitActual={userLimitActual}
|
||||
userLimitLimit={userLimitLimit}
|
||||
aiGovernanceUserFeature={aiGovernanceUserFeature}
|
||||
agentRuntimeHoursFeature={agentRuntimeHoursFeature}
|
||||
isRemoving={isRemovingLicense}
|
||||
onRemove={removeLicense}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user