mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
feat(site): show total agent hours consumption on the licenses page (#27953)
<img width="1195" height="165" alt="Screenshot 2026-08-19 at 5 31 17 PM" src="https://github.com/user-attachments/assets/d1e49e5f-1af6-4221-87fe-ae40997f0ae7" /> Adds a "Total agent hours" consumption panel to the Licenses page, surfacing the `agent_runtime_hours` entitlement introduced by the #27983 / #27984 / #27985 stack. The new `TotalAgentHoursCard` renders full width directly above the Agent Workspace Builds panel. It shows a usage bar with Used/Limit labels and an info tooltip next to the title. The bar color and tooltip copy track the same thresholds the backend uses for license warnings: green below the soft limit, orange once usage reaches the soft limit, and red once usage reaches the purchased allocation (values at or beyond the threshold count as reached, matching `appendAgentRuntimeHoursWarning`). When a soft limit exists, the tooltip states the actual soft-limit percentage computed from `soft_limit / limit`. The panel is hidden entirely when the feature is disabled, which is how the backend reports a zero-hour allocation. The usage period dates from `Feature.usage_period` render above the bar (start left, end right), matching the Agent Workspace Builds panel, so admins can tell which license period the consumption covers. The backend always attaches a period to an enabled feature; a missing or unparsable one just omits the dates row. The CODAGT-855 link to the per-user usage view is deferred: that view (F3, CODAGT-861) and the API it needs (F2, CODAGT-857) are not built yet, so there is no route to target. The link lands with F3. An enabled feature with `limit` omitted is the unlimited allocation (license claim `-1`, coder/license#49): the card shows a full neutral bar with "Limit: Unlimited" and the tooltip without threshold copy, mirroring `SeatUsageBarCard`'s unlimited state. The invalid-limits error is kept for negative limits, which can only come from a decoding bug. Storybook stories cover the green/orange/red states, the capped over-allocation bar, missing usage data, the missing usage period, the hidden state, the unlimited state, the invalid-limit error state, and each tooltip message via play interactions. The page view story verifies the panel renders above Agent Workspace Builds. Usage renders with exactly one decimal (e.g. `435.8`, `1,000.0`), derived from #27985's `actual_ms` field and floored to tenths with integer math. The same floored value drives the reached states, so the displayed number and the bar color flip at the same instant; a fraction past the allocation now trips the red state (`1,000.1 >= 1,000`), pinned by the `ReachedAllocationByFraction` story. The limit and hard cap labels stay whole because the license claims are whole hours. Stacked on #27985. closes [CODAGT-855](https://linear.app/codercom/issue/CODAGT-855/f1-runtime-hours-consumption-on-the-licenses-page).
This commit is contained in:
+37
-1
@@ -1,7 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import type { Feature } from "#/api/typesGenerated";
|
||||
import { MockLicenseResponse } from "#/testHelpers/entities";
|
||||
import {
|
||||
MockAgentRuntimeHoursFeature,
|
||||
MockLicenseResponse,
|
||||
} from "#/testHelpers/entities";
|
||||
import LicensesSettingsPageView from "./LicensesSettingsPageView";
|
||||
|
||||
const meta: Meta<typeof LicensesSettingsPageView> = {
|
||||
@@ -27,6 +30,10 @@ const meta: Meta<typeof LicensesSettingsPageView> = {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
} satisfies Feature,
|
||||
agentRuntimeHoursFeature: {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
} satisfies Feature,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,3 +78,32 @@ export const ActiveAIGovernanceAddOnUsage: Story = {
|
||||
await expect(canvas.getByText("1,000")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TotalAgentHoursUsage: Story = {
|
||||
args: {
|
||||
agentRuntimeHoursFeature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
limit: 2000,
|
||||
soft_limit: 1700,
|
||||
actual: 435,
|
||||
// 435 hours and 48 minutes: renders as 435.8.
|
||||
actual_ms: 435 * 3_600_000 + 48 * 60_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const agentHoursHeading = canvas.getByRole("heading", {
|
||||
name: "Total agent hours",
|
||||
});
|
||||
const agentHoursCard = within(agentHoursHeading.closest("section")!);
|
||||
await expect(agentHoursCard.getByText("435.8")).toBeInTheDocument();
|
||||
await expect(agentHoursCard.getByText("2,000")).toBeInTheDocument();
|
||||
const managedAgentsSection = canvas.getByText(
|
||||
"Agent Workspace Builds Disabled",
|
||||
);
|
||||
await expect(
|
||||
agentHoursHeading.compareDocumentPosition(managedAgentsSection) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
},
|
||||
};
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ import { LicenseCard } from "./LicenseCard";
|
||||
import { LicenseSeatConsumptionChart } from "./LicenseSeatConsumptionChart";
|
||||
import { ManagedAgentsConsumption } from "./ManagedAgentsConsumption";
|
||||
import { SeatUsageBarCard } from "./SeatUsageBarCard";
|
||||
import { TotalAgentHoursCard } from "./TotalAgentHoursCard";
|
||||
|
||||
type Props = {
|
||||
showConfetti: boolean;
|
||||
@@ -192,6 +193,8 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TotalAgentHoursCard feature={agentRuntimeHoursFeature} />
|
||||
|
||||
<ManagedAgentsConsumption
|
||||
managedAgentFeature={managedAgentFeature}
|
||||
/>
|
||||
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { MockAgentRuntimeHoursFeature } from "#/testHelpers/entities";
|
||||
import { TotalAgentHoursCard } from "./TotalAgentHoursCard";
|
||||
|
||||
const meta: Meta<typeof TotalAgentHoursCard> = {
|
||||
title:
|
||||
"pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard",
|
||||
component: TotalAgentHoursCard,
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
// 400 hours and 18 minutes: renders as 400.3.
|
||||
actual_ms: 400 * 3_600_000 + 18 * 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TotalAgentHoursCard>;
|
||||
|
||||
const hoverInfoIcon = async (canvasElement: HTMLElement) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.hover(
|
||||
canvas.getByRole("button", { name: "Total agent hours information" }),
|
||||
);
|
||||
return within(canvasElement.ownerDocument.body);
|
||||
};
|
||||
|
||||
// Radix mounts the role="tooltip" node only while the popover is open.
|
||||
// The node is the visually hidden copy of the content, so there is no
|
||||
// visibility to assert.
|
||||
const expectTooltipText = async (
|
||||
body: ReturnType<typeof within>,
|
||||
text: RegExp,
|
||||
) => {
|
||||
const tooltip = await body.findByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent(text);
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("400.3")).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/Warning:/)).toBeInTheDocument();
|
||||
await expect(canvas.getByText("850")).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/Allocation:/)).toBeInTheDocument();
|
||||
await expect(canvas.getByText("1,000")).toBeInTheDocument();
|
||||
await expect(canvas.queryByText(/Limit:/)).not.toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText("(June 1, 2026 - May 31, 2027)"),
|
||||
).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/Total time agents have been working across all workspaces this license\. A soft-limit warning appears at 85%/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NoSoftLimit: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
soft_limit: undefined,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByText(/Warning:/)).not.toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/^Total time agents have been working across all workspaces this license\.$/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ReachedSoftLimit: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
actual: 850,
|
||||
actual_ms: 850 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("850.0")).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 85% or more of your Total Agent hours for this license\. Agent sessions are still working normally, but you'll want to plan for the 100% limit\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ReachedAllocation: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
actual: 1000,
|
||||
actual_ms: 1000 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,000.0")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("1,000")).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 100% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OverAllocation: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
actual: 1200,
|
||||
actual_ms: 1200 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,200.0")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("1,000")).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 120% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// The extra 6 minutes alone push the tenths value past the whole-hour
|
||||
// allocation and flip the reached state.
|
||||
export const ReachedAllocationByFraction: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
actual: 1000,
|
||||
actual_ms: 1000 * 3_600_000 + 6 * 60_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,000.1")).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 100% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Integer claims such as 29/100 must not underflow to 28.9% after
|
||||
// flooring a binary ratio ((29 / 100) * 100 === 28.999...).
|
||||
export const SoftLimitExactPercent: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
limit: 100,
|
||||
soft_limit: 29,
|
||||
actual: 10,
|
||||
actual_ms: 10 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("10.0")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("29")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("100")).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/Total time agents have been working across all workspaces this license\. A soft-limit warning appears at 29%/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// 99.9% must never read as a false 100%.
|
||||
export const NearAllocation: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
soft_limit: 999,
|
||||
actual: 999,
|
||||
actual_ms: 999 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("999.0")).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 99\.9% or more of your Total Agent hours for this license\. Agent sessions are still working normally, but you'll want to plan for the 100% limit\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingActual: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
actual: undefined,
|
||||
actual_ms: undefined,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("N/A")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// Missing usage data must not count as reaching a zero soft limit, so
|
||||
// the tooltip stays informational.
|
||||
export const MissingActualZeroSoftLimit: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
soft_limit: 0,
|
||||
actual: undefined,
|
||||
actual_ms: undefined,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("N/A")).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/^Total time agents have been working across all workspaces this license\. A soft-limit warning appears at 0%$/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingUsagePeriod: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
usage_period: undefined,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("400.0")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByText("(June 1, 2026 - May 31, 2027)"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const HardCap: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
hard_limit: 1500,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("400.0")).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/Warning:/)).toBeInTheDocument();
|
||||
await expect(canvas.getByText("850")).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/Allocation:/)).toBeInTheDocument();
|
||||
await expect(canvas.getByText("1,000")).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/Limit:/)).toBeInTheDocument();
|
||||
await expect(canvas.getByText("1,500")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByText(
|
||||
"Agent hours limit reached. Concurrent chats are now limited to 5.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/^Total time agents have been working across all workspaces this license\. A soft-limit warning appears at 85%$/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const HardCapBetweenSoftLimitAndLimit: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
hard_limit: 1500,
|
||||
actual: 900,
|
||||
actual_ms: 900 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("900.0")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByText(
|
||||
"Agent hours limit reached. Concurrent chats are now limited to 5.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 85% or more of your Total Agent hours for this license\. Agent sessions are still working normally, but you'll want to plan for the 100% limit\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const HardCapBetweenLimitAndHardCap: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
hard_limit: 1500,
|
||||
actual: 1200,
|
||||
actual_ms: 1200 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,200.0")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByText(
|
||||
"Agent hours limit reached. Concurrent chats are now limited to 5.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 120% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ReachedHardCap: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
hard_limit: 1500,
|
||||
actual: 1600,
|
||||
actual_ms: 1600 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,600.0")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText(
|
||||
"Agent hours limit reached. Concurrent chats are now limited to 5.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 160% of your Total Agent hours for this license and reached the hard cap of 1,500 hours\. Contact sales to receive more Agent hours\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// The backend accepts a hard cap equal to the allocation.
|
||||
export const ReachedCoincidentHardCap: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
hard_limit: 1000,
|
||||
actual: 1000,
|
||||
actual_ms: 1000 * 3_600_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,000.0")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText(
|
||||
"Agent hours limit reached. Concurrent chats are now limited to 5.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/You've used 100% of your Total Agent hours for this license and reached the hard cap of 1,000 hours\. Contact sales to receive more Agent hours\./,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.queryByRole("heading", { name: "Total agent hours" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Unlimited: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
limit: undefined,
|
||||
soft_limit: undefined,
|
||||
actual: 1200,
|
||||
// 1,200 hours and 30 minutes: renders as 1,200.5.
|
||||
actual_ms: 1200 * 3_600_000 + 30 * 60_000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,200.5")).toBeInTheDocument();
|
||||
await expect(canvas.queryByText(/Warning:/)).not.toBeInTheDocument();
|
||||
await expect(canvas.getByText(/Allocation:/)).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Unlimited")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByText("Invalid license usage limits"),
|
||||
).not.toBeInTheDocument();
|
||||
const body = await hoverInfoIcon(canvasElement);
|
||||
await expectTooltipText(
|
||||
body,
|
||||
/^Total time agents have been working across all workspaces this license\.$/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorInvalidLimit: Story = {
|
||||
args: {
|
||||
feature: {
|
||||
...MockAgentRuntimeHoursFeature,
|
||||
// A negative limit can only come from a decoding bug: the
|
||||
// claim-level unlimited sentinel decodes to an omitted limit,
|
||||
// never a negative one.
|
||||
limit: -100,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByText("Invalid license usage limits"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import dayjs from "dayjs";
|
||||
import { BanIcon, InfoIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { Feature } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
type TotalAgentHoursCardProps = {
|
||||
feature?: Feature;
|
||||
};
|
||||
|
||||
export const TotalAgentHoursCard: FC<TotalAgentHoursCardProps> = ({
|
||||
feature,
|
||||
}) => {
|
||||
// A zero-hour allocation arrives with enabled=false, which hides the
|
||||
// panel entirely rather than showing an empty bar.
|
||||
if (!feature?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
limit,
|
||||
soft_limit: softLimit,
|
||||
hard_limit: hardLimit,
|
||||
actual_ms: actualMs,
|
||||
usage_period: usagePeriod,
|
||||
} = feature;
|
||||
|
||||
// An omitted limit means the license grants unlimited runtime hours.
|
||||
const isUnlimited = limit === undefined;
|
||||
|
||||
if (!isUnlimited && limit < 0) {
|
||||
return (
|
||||
<section className="border border-solid rounded">
|
||||
<div className="p-4">
|
||||
<ErrorAlert error="Invalid license usage limits" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const meteredLimit = limit ?? 0;
|
||||
const usedTenths =
|
||||
actualMs === undefined ? 0 : Math.floor(actualMs / 360_000);
|
||||
const usedHours = usedTenths / 10;
|
||||
const hardCap =
|
||||
!isUnlimited &&
|
||||
hardLimit !== undefined &&
|
||||
hardLimit > 0 &&
|
||||
hardLimit >= meteredLimit
|
||||
? hardLimit
|
||||
: undefined;
|
||||
const reachedAllocation =
|
||||
!isUnlimited && actualMs !== undefined && usedHours >= meteredLimit;
|
||||
const reachedHardCap =
|
||||
hardCap !== undefined && actualMs !== undefined && usedHours >= hardCap;
|
||||
const reachedSoftLimit =
|
||||
!isUnlimited &&
|
||||
!reachedAllocation &&
|
||||
actualMs !== undefined &&
|
||||
softLimit !== undefined &&
|
||||
usedHours >= softLimit;
|
||||
const barScale = hardCap ?? meteredLimit;
|
||||
const usagePercentage = isUnlimited
|
||||
? 100
|
||||
: barScale > 0
|
||||
? Math.min((usedHours / barScale) * 100, 100)
|
||||
: 0;
|
||||
const allocationMarkerPercent =
|
||||
hardCap === undefined
|
||||
? undefined
|
||||
: Math.min((meteredLimit / hardCap) * 100, 100);
|
||||
|
||||
const softMarkerPercent =
|
||||
!isUnlimited && softLimit !== undefined && barScale > 0
|
||||
? Math.min((softLimit / barScale) * 100, 100)
|
||||
: undefined;
|
||||
const fillClassName = reachedAllocation
|
||||
? "bg-highlight-red"
|
||||
: reachedSoftLimit
|
||||
? "bg-border-warning"
|
||||
: "bg-highlight-green";
|
||||
|
||||
// Already floored to tenths, so rendering one decimal never rounds.
|
||||
const usedLabel =
|
||||
actualMs === undefined
|
||||
? "N/A"
|
||||
: usedHours.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const warningLabel =
|
||||
!isUnlimited && softLimit !== undefined
|
||||
? softLimit.toLocaleString("en-US")
|
||||
: undefined;
|
||||
const allocationLabel = isUnlimited
|
||||
? "Unlimited"
|
||||
: meteredLimit.toLocaleString("en-US");
|
||||
const limitLabel = hardCap?.toLocaleString("en-US");
|
||||
|
||||
const periodStart = usagePeriod ? dayjs(usagePeriod.start) : undefined;
|
||||
const periodEnd = usagePeriod ? dayjs(usagePeriod.end) : undefined;
|
||||
const usagePeriodLabel =
|
||||
periodStart?.isValid() && periodEnd?.isValid()
|
||||
? `(${periodStart.format("MMMM D, YYYY")} - ${periodEnd.format("MMMM D, YYYY")})`
|
||||
: undefined;
|
||||
|
||||
const formatPercent = (numerator: number, denominator: number): string =>
|
||||
(Math.floor((numerator * 1000) / denominator) / 10).toLocaleString("en-US");
|
||||
|
||||
const softLimitPercent =
|
||||
!isUnlimited && softLimit !== undefined && meteredLimit > 0
|
||||
? formatPercent(softLimit, meteredLimit)
|
||||
: undefined;
|
||||
|
||||
// A fading green fill reads as an unmetered allocation rather than
|
||||
// 100% usage. The mask needs the -webkit- prefix for Safari.
|
||||
const unlimitedBarClassName = cn(
|
||||
"bg-highlight-green",
|
||||
"[mask-image:linear-gradient(to_right,black_50%,transparent_100%)]",
|
||||
"[-webkit-mask-image:linear-gradient(to_right,black_50%,transparent_100%)]",
|
||||
);
|
||||
|
||||
let tooltip: string;
|
||||
if (reachedAllocation) {
|
||||
const usedPercent =
|
||||
meteredLimit > 0 ? formatPercent(usedTenths, meteredLimit * 10) : "100";
|
||||
tooltip = reachedHardCap
|
||||
? `You've used ${usedPercent}% of your Total Agent hours for this license and reached the hard cap of ${limitLabel} hours. Contact sales to receive more Agent hours.`
|
||||
: `You've used ${usedPercent}% of your Total Agent hours for this license. Contact sales to receive more Agent hours.`;
|
||||
} else if (reachedSoftLimit) {
|
||||
tooltip = `You've used ${softLimitPercent}% or more of your Total Agent hours for this license. Agent sessions are still working normally, but you'll want to plan for the 100% limit.`;
|
||||
} else if (softLimitPercent !== undefined) {
|
||||
tooltip = `Total time agents have been working across all workspaces this license. A soft-limit warning appears at ${softLimitPercent}%`;
|
||||
} else {
|
||||
tooltip =
|
||||
"Total time agents have been working across all workspaces this license.";
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border border-solid rounded">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="text-md m-0 font-medium">Total agent hours</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Total agent hours 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>
|
||||
{usagePeriodLabel && (
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
{usagePeriodLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reachedHardCap && (
|
||||
<div className="flex items-center justify-end">
|
||||
{/* The concurrency cap re-engages at usage equal to the
|
||||
hard cap, so the badge shows at the inclusive boundary
|
||||
and reads "reached" (still true above it) rather than
|
||||
"exceeded" (false at equality). The sentence outgrows
|
||||
narrow cards, so this instance wraps instead of
|
||||
keeping the shared Badge's single-line layout. */}
|
||||
<Badge
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-auto rounded-full text-wrap"
|
||||
>
|
||||
<BanIcon />
|
||||
Agent hours limit reached. Concurrent chats are now limited to
|
||||
5.
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative h-5 w-full overflow-hidden bg-surface-secondary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{!isUnlimited && (
|
||||
<>
|
||||
{softMarkerPercent !== undefined && (
|
||||
<div
|
||||
className="absolute inset-y-0 z-0 border-0 border-l-2 border-dotted border-border-warning"
|
||||
style={{ left: `${softMarkerPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
{hardCap === undefined ? (
|
||||
// The allocation marker sits at the track's right edge.
|
||||
<div className="absolute inset-y-0 right-0 z-0 w-0.5 bg-highlight-red" />
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-y-0 z-0 w-0.5 bg-highlight-red"
|
||||
style={{ left: `${allocationMarkerPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isUnlimited ? (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 h-full w-full",
|
||||
unlimitedBarClassName,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 h-full transition-[width] duration-300",
|
||||
fillClassName,
|
||||
)}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3 text-sm font-medium">
|
||||
<p className="m-0 whitespace-nowrap text-content-primary">
|
||||
<span className="text-content-secondary">Used: </span>
|
||||
<span
|
||||
className={cn({
|
||||
"text-content-destructive": reachedAllocation,
|
||||
"text-border-warning": reachedSoftLimit,
|
||||
})}
|
||||
>
|
||||
{usedLabel}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-end gap-x-3 gap-y-1 whitespace-nowrap text-content-secondary">
|
||||
{warningLabel !== undefined && (
|
||||
<p className="m-0">
|
||||
Warning:{" "}
|
||||
<span className="text-content-primary">{warningLabel}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="m-0">
|
||||
Allocation:{" "}
|
||||
<span className="text-content-primary">{allocationLabel}</span>
|
||||
</p>
|
||||
{limitLabel !== undefined && (
|
||||
<p className="m-0">
|
||||
Limit:{" "}
|
||||
<span className="text-content-primary">{limitLabel}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -2701,6 +2701,20 @@ export const mockApiError = ({
|
||||
},
|
||||
});
|
||||
|
||||
export const MockAgentRuntimeHoursFeature: TypesGen.Feature = {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
limit: 1000,
|
||||
soft_limit: 850,
|
||||
actual: 400,
|
||||
actual_ms: 400 * 3_600_000,
|
||||
usage_period: {
|
||||
issued_at: "June 1, 2026",
|
||||
start: "June 1, 2026",
|
||||
end: "May 31, 2027",
|
||||
},
|
||||
};
|
||||
|
||||
export const MockEntitlements: TypesGen.Entitlements = {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
|
||||
Reference in New Issue
Block a user