Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const meta: Meta<typeof LicensesSettingsPageView> = {
enabled: false,
entitlement: "not_entitled",
} satisfies Feature,
agentRuntimeHoursFeature: {
enabled: false,
entitlement: "not_entitled",
} satisfies Feature,
},
};

Expand Down Expand Up @@ -71,3 +75,31 @@ export const ActiveAIGovernanceAddOnUsage: Story = {
await expect(canvas.getByText("1,000")).toBeInTheDocument();
},
};

/** The Total agent hours panel renders full width, directly above Agent Workspace Builds. */
export const TotalAgentHoursUsage: Story = {
args: {
agentRuntimeHoursFeature: {
enabled: true,
entitlement: "entitled",
limit: 2000,
soft_limit: 1700,
actual: 435,
} satisfies Feature,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const agentHoursHeading = canvas.getByRole("heading", {
name: "Total agent hours",
});
await expect(canvas.getByText("435")).toBeInTheDocument();
await expect(canvas.getByText("2,000")).toBeInTheDocument();
const managedAgentsSection = canvas.getByText(
"Agent Workspace Builds Disabled",
);
await expect(
agentHoursHeading.compareDocumentPosition(managedAgentsSection) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +41,7 @@ type Props = {
activeUsers: UserStatusChangeCount[] | undefined;
managedAgentFeature?: Feature;
aiGovernanceUserFeature?: Feature;
agentRuntimeHoursFeature?: Feature;
};

const LicensesSettingsPageView: FC<Props> = ({
Expand All @@ -56,6 +58,7 @@ const LicensesSettingsPageView: FC<Props> = ({
activeUsers,
managedAgentFeature,
aiGovernanceUserFeature,
agentRuntimeHoursFeature,
}) => {
const theme = useTheme();
const { width, height } = useWindowSize();
Expand Down Expand Up @@ -189,6 +192,8 @@ const LicensesSettingsPageView: FC<Props> = ({
/>
</div>

<TotalAgentHoursCard feature={agentRuntimeHoursFeature} />

<ManagedAgentsConsumption
managedAgentFeature={managedAgentFeature}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import type { Feature } from "#/api/typesGenerated";
import { TotalAgentHoursCard } from "./TotalAgentHoursCard";

const meta: Meta<typeof TotalAgentHoursCard> = {
title:
"pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard",
component: TotalAgentHoursCard,
args: {
feature: {
enabled: true,
entitlement: "entitled",
limit: 1000,
soft_limit: 850,
actual: 400,
} satisfies Feature,
},
};

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);
};

// Tooltip text appears in both the tooltip popover and its hidden
// accessibility duplicate, so assertions match all occurrences.
const expectTooltipText = async (
body: ReturnType<typeof within>,
text: RegExp,
) => {
const matches = await body.findAllByText(text);
expect(matches.length).toBeGreaterThanOrEqual(1);
};

export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("400")).toBeInTheDocument();
await expect(canvas.getByText("1,000")).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: {
enabled: true,
entitlement: "entitled",
limit: 1000,
actual: 400,
} satisfies Feature,
},
play: async ({ canvasElement }) => {
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: {
enabled: true,
entitlement: "entitled",
limit: 1000,
soft_limit: 850,
actual: 850,
} satisfies Feature,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("850")).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: {
enabled: true,
entitlement: "entitled",
limit: 1000,
soft_limit: 850,
actual: 1000,
} satisfies Feature,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getAllByText("1,000")).toHaveLength(2);
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: {
enabled: true,
entitlement: "entitled",
limit: 1000,
soft_limit: 850,
actual: 1200,
} satisfies Feature,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("1,200")).toBeInTheDocument();
await expect(canvas.getByText("1,000")).toBeInTheDocument();
},
};

export const MissingActual: Story = {
args: {
feature: {
enabled: true,
entitlement: "entitled",
limit: 1000,
soft_limit: 850,
} satisfies Feature,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("\u2014")).toBeInTheDocument();
},
};

export const Disabled: Story = {
args: {
feature: {
enabled: false,
entitlement: "not_entitled",
} satisfies Feature,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("heading", { name: "Total agent hours" }),
).not.toBeInTheDocument();
},
};

export const ErrorInvalidLimit: Story = {
args: {
feature: {
enabled: true,
entitlement: "entitled",
actual: 100,
} satisfies Feature,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("Invalid license usage limits"),
).toBeInTheDocument();
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { InfoIcon } from "lucide-react";
import type { FC } from "react";
import type { Feature } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
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, actual } = feature;

if (limit === undefined || limit < 0) {
return (
<section className="border border-solid rounded">
<div className="p-4">
<ErrorAlert error="Invalid license usage limits" />
</div>
</section>
);
}

const usedHours = actual ?? 0;
// The backend warns with >= for both thresholds, so "reached" (not
// "exceeded") flips the bar color.
const reachedAllocation = actual !== undefined && usedHours >= limit;
const reachedSoftLimit =
!reachedAllocation && softLimit !== undefined && usedHours >= softLimit;
const usagePercentage =
limit > 0 ? Math.min((usedHours / limit) * 100, 100) : 0;

const usedLabel =
actual === undefined ? "\u2014" : usedHours.toLocaleString("en-US");
const limitLabel = limit.toLocaleString("en-US");

const softLimitPercent =
softLimit !== undefined && limit > 0
? Math.round((softLimit / limit) * 100)
: undefined;

let tooltip: string;
if (reachedAllocation) {
tooltip =
"You've used 100% 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 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>

<div
className="relative h-5 w-full overflow-hidden rounded bg-surface-secondary"
aria-hidden="true"
>
<div
className={cn(
"h-full rounded-l transition-[width] duration-300",
reachedAllocation
? "bg-highlight-red"
: reachedSoftLimit
? "bg-highlight-orange"
: "bg-highlight-green",
)}
style={{ width: `${usagePercentage}%` }}
/>
</div>

<div className="flex items-start justify-between text-sm font-medium whitespace-nowrap">
<p className="m-0 text-content-primary">
<span className="text-content-secondary">Used: </span>
<span
className={cn({
"text-content-destructive": reachedAllocation,
})}
>
{usedLabel}
</span>
</p>
<p className="m-0 text-content-secondary">
Limit: <span className="text-content-primary">{limitLabel}</span>
</p>
</div>
</div>
</div>
</section>
);
};
Loading