Skip to content
9 changes: 9 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,15 @@ type Claims = {
license_expires: number;
// nbf is a standard JWT claim for "not before" - the license valid from date
nbf?: number;
// iat is a standard JWT claim for "issued at". Valid licenses always
// carry it; the merged entitlement's usage_period.issued_at is stamped
// from the winning license's iat.
iat?: number;
// exp is a standard JWT claim for "expires at": the end of the grace
// period (identical to license_expires when there is no grace period).
// The merged entitlement's usage_period.end is stamped from the
// winning license's exp, and usage_period.start from its nbf.
exp?: number;
account_type?: string;
account_id?: string;
trial: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -45,10 +27,7 @@ export function hasAiGovernanceAddOnLicense(
licenses?.some(
(license) =>
licenseShowsAiGovernanceAddOn(license) &&
isLicenseApplicableForAiGovernanceOverage(
license,
aiGovernanceUserFeature,
),
isLicenseApplicableForFeatureUsage(license, aiGovernanceUserFeature),
) ?? false
);
}
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, waitFor, within } from "storybook/test";
import { CoderAgentsProductCard } from "./CoderAgentsProductCard";

const meta: Meta<typeof CoderAgentsProductCard> = {
title:
"pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard",
component: CoderAgentsProductCard,
args: {
allocation: 20000,
// Fractional usage renders with one decimal; whole values render
// with a trailing .0 (see Exceeded).
actual: 16264.3,
isExceeded: false,
isHardLimitExceeded: false,
},
};

export default meta;
type Story = StoryObj<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, userEvent, step }) => {
const canvas = within(canvasElement);
await step("open the Total Agent hours tooltip from keyboard", async () => {
await userEvent.tab();
await expect(
canvas.getByRole("button", { name: "Total Agent hours information" }),
).toHaveFocus();
await waitFor(async () => {
await expect(screen.getByRole("tooltip")).toHaveTextContent(
"Total agent runtime hours used out of the hours included in this license.",
);
});
await userEvent.keyboard("{Escape}");
await waitFor(async () => {
await expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});
await step("open the Concurrent chats tooltip on hover", async () => {
await userEvent.hover(
canvas.getByRole("button", { name: "Concurrent chats information" }),
);
await waitFor(async () => {
await expect(screen.getByRole("tooltip")).toHaveTextContent(
"Number of Coder Agents chats that can run at the same time.",
);
});
});
},
};

export const UnlimitedAllocation: Story = {
args: {
allocation: -1,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
"Unlimited",
);
await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
"Unlimited",
);
},
};

export const NotProvidingUsage: Story = {
args: {
actual: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
"\u2014 / 20,000",
);
},
};

export const Exceeded: Story = {
args: {
actual: 21000,
isExceeded: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
"21,000.0 / 20,000",
);
await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
"Unlimited",
);
},
};

export const HardLimitExceeded: Story = {
args: {
actual: 25000,
isHardLimitExceeded: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
"25,000.0 / 20,000",
);
await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
"5",
);
},
};

export const NoAllocation: Story = {
args: {
allocation: undefined,
actual: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
getMetricValue(canvas, "Max concurrent chats"),
).toHaveTextContent("5");
await expect(
canvas.queryByText(/Agent hours used/),
).not.toBeInTheDocument();
const upgrade = canvas.getByRole("link", { name: "Upgrade" });
await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com");
},
};

export const NoAllocationWithUsage: Story = {
args: {
allocation: undefined,
actual: 1234.5,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent(
"Agent hours used: 1,234.5",
);
await expect(
canvas.getByRole("link", { name: "Upgrade" }),
).toBeInTheDocument();
},
};
Loading
Loading