mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add bar charts for premium and AI governance add-on license usage (#23442)
Implemented with the help of Cursor agents using Figma MCP Figma design: https://www.figma.com/design/klGTlHSPQwI4KBvAMdebrx/Customer-Usage-Controls-for-AI-Governance-Add-On?node-id=448-7658&m=dev <img width="1143" height="639" alt="Screenshot 2026-03-23 at 20 10 05" src="https://github.com/user-attachments/assets/300d4d5d-aad2-49a9-bfdd-a329312e5fa8" />
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import type { GetLicensesResponse } from "api/api";
|
||||
import type { Feature } from "api/typesGenerated";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
function isPremiumLicense(license: GetLicensesResponse): boolean {
|
||||
return license.claims.feature_set?.toLowerCase() === "premium";
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the license card add-on section: Premium license with the
|
||||
* ai_governance add-on in claims.
|
||||
*/
|
||||
export function licenseShowsAiGovernanceAddOn(
|
||||
license: GetLicensesResponse,
|
||||
): boolean {
|
||||
return (
|
||||
isPremiumLicense(license) &&
|
||||
Boolean(license.claims.addons?.includes("ai_governance"))
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
): boolean {
|
||||
return (
|
||||
licenses?.some(
|
||||
(license) =>
|
||||
licenseShowsAiGovernanceAddOn(license) &&
|
||||
isLicenseApplicableForAiGovernanceOverage(
|
||||
license,
|
||||
aiGovernanceUserFeature,
|
||||
),
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort limit from license JWT claims when merged entitlements are not
|
||||
* yet available. Uses the same per-license field as the add-on card.
|
||||
*/
|
||||
function aiGovernanceLimitFromLicenses(
|
||||
licenses: GetLicensesResponse[],
|
||||
aiGovernanceUserFeature: Feature | undefined,
|
||||
): number | undefined {
|
||||
const limits = licenses
|
||||
.filter(
|
||||
(license) =>
|
||||
licenseShowsAiGovernanceAddOn(license) &&
|
||||
isLicenseApplicableForAiGovernanceOverage(
|
||||
license,
|
||||
aiGovernanceUserFeature,
|
||||
),
|
||||
)
|
||||
.map((license) => license.claims.features?.ai_governance_user_limit)
|
||||
.filter((limit): limit is number => limit !== undefined);
|
||||
return limits.length > 0 ? Math.max(...limits) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the displayed AI governance user limit for summary charts.
|
||||
*
|
||||
* When the feature is not entitled yet, JWT claims can still carry the
|
||||
* purchased add-on seat limit while entitlements may report limit 0.
|
||||
*/
|
||||
export function effectiveAiGovernanceLimitForUsageCard(
|
||||
aiGovernanceUserFeature: Feature | undefined,
|
||||
licenses: GetLicensesResponse[] | undefined,
|
||||
): number | undefined {
|
||||
const limitFromClaims = licenses
|
||||
? aiGovernanceLimitFromLicenses(licenses, aiGovernanceUserFeature)
|
||||
: undefined;
|
||||
const limitFromEntitlements = aiGovernanceUserFeature?.limit;
|
||||
|
||||
return aiGovernanceUserFeature?.enabled === true
|
||||
? (limitFromEntitlements ?? limitFromClaims)
|
||||
: (limitFromClaims ?? limitFromEntitlements);
|
||||
}
|
||||
+95
-5
@@ -1,7 +1,59 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { GetLicensesResponse } from "api/api";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { AIGovernanceUsersConsumption } from "./AIGovernanceUsersConsumptionChart";
|
||||
|
||||
const licenseWithAiGovernanceAddOn: GetLicensesResponse = {
|
||||
id: 42,
|
||||
uploaded_at: "1660104000",
|
||||
expires_at: "3420244800",
|
||||
uuid: "license-ai-gov-addon",
|
||||
claims: {
|
||||
trial: false,
|
||||
all_features: true,
|
||||
feature_set: "premium",
|
||||
version: 1,
|
||||
addons: ["ai_governance"],
|
||||
features: { ai_governance_user_limit: 750 },
|
||||
license_expires: 3420244800,
|
||||
nbf: 1660104000,
|
||||
},
|
||||
};
|
||||
|
||||
const higherApplicableAiGovernanceLicense: GetLicensesResponse = {
|
||||
id: 43,
|
||||
uploaded_at: "1660104000",
|
||||
expires_at: "3420244800",
|
||||
uuid: "license-ai-gov-addon-higher",
|
||||
claims: {
|
||||
trial: false,
|
||||
all_features: true,
|
||||
feature_set: "premium",
|
||||
version: 1,
|
||||
addons: ["ai_governance"],
|
||||
features: { ai_governance_user_limit: 900 },
|
||||
license_expires: 3420244800,
|
||||
nbf: 1660104000,
|
||||
},
|
||||
};
|
||||
|
||||
const nonApplicableAiGovernanceLicense: GetLicensesResponse = {
|
||||
id: 44,
|
||||
uploaded_at: "1660104000",
|
||||
expires_at: "3420244800",
|
||||
uuid: "license-ai-gov-addon-non-applicable",
|
||||
claims: {
|
||||
trial: false,
|
||||
all_features: true,
|
||||
feature_set: "enterprise",
|
||||
version: 1,
|
||||
addons: ["ai_governance"],
|
||||
features: { ai_governance_user_limit: 1200 },
|
||||
license_expires: 3420244800,
|
||||
nbf: 1660104000,
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof AIGovernanceUsersConsumption> = {
|
||||
title:
|
||||
"pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart",
|
||||
@@ -11,8 +63,9 @@ const meta: Meta<typeof AIGovernanceUsersConsumption> = {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
limit: 1000,
|
||||
actual: 750,
|
||||
actual: 512,
|
||||
},
|
||||
licenses: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,10 +86,7 @@ export const Exceeded: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,200")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText(/of 1,000 Users Entitled/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("1,000")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,12 +96,52 @@ export const Disabled: Story = {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
},
|
||||
licenses: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoFeature: Story = {
|
||||
args: {
|
||||
aiGovernanceUserFeature: undefined,
|
||||
licenses: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** Entitlements not enabled, but a Premium license lists the add-on and limit in JWT claims. */
|
||||
export const UsageBarFromLicenseClaims: Story = {
|
||||
args: {
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
},
|
||||
licenses: [licenseWithAiGovernanceAddOn],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("750")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("heading", { name: "AI governance add-on usage" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Picks the highest applicable limit when multiple licenses are present. */
|
||||
export const UsageBarUsesHighestApplicableLicenseLimit: Story = {
|
||||
args: {
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
},
|
||||
licenses: [
|
||||
licenseWithAiGovernanceAddOn,
|
||||
higherApplicableAiGovernanceLicense,
|
||||
nonApplicableAiGovernanceLicense,
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("900")).toBeInTheDocument();
|
||||
await expect(canvas.queryByText("1,200")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+31
-133
@@ -1,31 +1,40 @@
|
||||
import type { GetLicensesResponse } from "api/api";
|
||||
import type { Feature } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "components/Collapsible/Collapsible";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { docs } from "utils/docs";
|
||||
import {
|
||||
effectiveAiGovernanceLimitForUsageCard,
|
||||
hasAiGovernanceAddOnLicense,
|
||||
} from "./AIGovernanceLicensing";
|
||||
import { SeatUsageBarCard } from "./SeatUsageBarCard";
|
||||
|
||||
interface AIGovernanceUsersConsumptionProps {
|
||||
aiGovernanceUserFeature?: Feature;
|
||||
licenses?: GetLicensesResponse[];
|
||||
}
|
||||
|
||||
export const AIGovernanceUsersConsumption: FC<
|
||||
AIGovernanceUsersConsumptionProps
|
||||
> = ({ aiGovernanceUserFeature }) => {
|
||||
// If no feature is provided or it's disabled, show disabled state
|
||||
if (!aiGovernanceUserFeature?.enabled) {
|
||||
> = ({ aiGovernanceUserFeature, licenses }) => {
|
||||
const hasAddOnLicense = hasAiGovernanceAddOnLicense(
|
||||
licenses,
|
||||
aiGovernanceUserFeature,
|
||||
);
|
||||
const effectiveLimit = effectiveAiGovernanceLimitForUsageCard(
|
||||
aiGovernanceUserFeature,
|
||||
licenses,
|
||||
);
|
||||
|
||||
const showUsageBar =
|
||||
aiGovernanceUserFeature?.enabled === true ||
|
||||
(hasAddOnLicense && effectiveLimit !== undefined);
|
||||
|
||||
if (!showUsageBar) {
|
||||
return (
|
||||
<div className="min-h-60 flex items-center justify-center rounded-lg border border-solid p-12">
|
||||
<div className="flex flex-col gap-4 items-center justify-center">
|
||||
<div className="flex flex-col gap-2 items-center justify-center">
|
||||
<span className="text-base">Users with AI Governance Add-On</span>
|
||||
<div className="flex items-center justify-center rounded-lg border border-solid p-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="text-base">AI governance add-on usage</span>
|
||||
<span className="text-content-secondary text-center max-w-[464px] mt-2">
|
||||
AI Governance is not included in your current license. Contact{" "}
|
||||
<Link href="mailto:sales@coder.com">sales</Link> to upgrade your
|
||||
@@ -37,122 +46,11 @@ export const AIGovernanceUsersConsumption: FC<
|
||||
);
|
||||
}
|
||||
|
||||
const limit = aiGovernanceUserFeature.limit;
|
||||
|
||||
if (limit === undefined || limit < 0) {
|
||||
return <ErrorAlert error="Invalid license usage limits" />;
|
||||
}
|
||||
|
||||
const actual = aiGovernanceUserFeature.actual;
|
||||
const isExceeded = actual !== undefined && actual > limit;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn("border border-solid rounded", {
|
||||
"border-border-destructive": isExceeded,
|
||||
})}
|
||||
>
|
||||
<div className="p-4">
|
||||
<Collapsible>
|
||||
<header className="flex flex-col gap-2 items-start">
|
||||
<h3 className="text-md m-0 font-medium">
|
||||
Users with AI Governance Add-On
|
||||
</h3>
|
||||
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={`
|
||||
h-auto p-0 border-0 bg-transparent font-medium text-content-secondary
|
||||
hover:bg-transparent hover:text-content-primary
|
||||
[&[data-state=open]_svg]:rotate-90
|
||||
`}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
Learn more
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</header>
|
||||
|
||||
<CollapsibleContent
|
||||
className={`
|
||||
pt-2 pl-7 pr-5 space-y-4 font-medium max-w-[720px]
|
||||
text-sm text-content-secondary
|
||||
[&_p]:m-0 [&_ul]:m-0 [&_ul]:p-0 [&_ul]:list-none
|
||||
`}
|
||||
>
|
||||
<p>
|
||||
Users using AI features like{" "}
|
||||
<Link
|
||||
href={docs("/ai-coder/ai-bridge")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
AI Bridge
|
||||
</Link>
|
||||
,{" "}
|
||||
<Link
|
||||
href={docs("/ai-coder/boundary/agent-boundary")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Boundary
|
||||
</Link>
|
||||
, or{" "}
|
||||
<Link
|
||||
href={docs("/ai-coder/tasks")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Tasks
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-12 border-0 border-t border-solid">
|
||||
<div className="flex flex-col gap-4 text-center justify-center items-center">
|
||||
{actual !== undefined ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 text-center justify-center items-center">
|
||||
<div
|
||||
className={cn("text-3xl font-bold", {
|
||||
"text-content-destructive": isExceeded,
|
||||
})}
|
||||
>
|
||||
{actual.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-content-secondary">
|
||||
of {limit.toLocaleString()} Users Entitled
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn("text-sm", {
|
||||
"text-content-destructive font-medium": isExceeded,
|
||||
"text-content-secondary": !isExceeded,
|
||||
})}
|
||||
>
|
||||
{isExceeded ? "Add-on exceeded" : "Active"}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 text-center justify-center items-center">
|
||||
<div className="text-3xl font-bold">
|
||||
{limit.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-content-secondary">
|
||||
Users Entitled
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-content-secondary">
|
||||
Additional analytics and measurements coming soon
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<SeatUsageBarCard
|
||||
title="AI governance add-on usage"
|
||||
actual={aiGovernanceUserFeature?.actual}
|
||||
limit={effectiveLimit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,10 @@ import { ChevronDownIcon, EllipsisVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard";
|
||||
import {
|
||||
isLicenseApplicableForAiGovernanceOverage,
|
||||
licenseShowsAiGovernanceAddOn,
|
||||
} from "./AIGovernanceLicensing";
|
||||
|
||||
type LicenseCardProps = {
|
||||
license: GetLicensesResponse;
|
||||
@@ -61,21 +65,19 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
? "Premium"
|
||||
: "Enterprise";
|
||||
|
||||
const hasExplicitAiGovernanceAddOn = (license.claims.addons ?? []).includes(
|
||||
"ai_governance",
|
||||
);
|
||||
const isAiGovernanceEntitlementInGracePeriod =
|
||||
aiGovernanceUserFeature?.entitlement === "grace_period";
|
||||
const hasExplicitAiGovernanceAddOn = licenseShowsAiGovernanceAddOn(license);
|
||||
// Overage/display checks only apply to licenses that are currently effective.
|
||||
const isLicenseApplicableForAiGovernanceOverage =
|
||||
!isNotYetValid && (!isExpired || isAiGovernanceEntitlementInGracePeriod);
|
||||
const isLicenseApplicable = isLicenseApplicableForAiGovernanceOverage(
|
||||
license,
|
||||
aiGovernanceUserFeature,
|
||||
);
|
||||
// A license "wins" when its AI governance limit matches the merged limit.
|
||||
const isWinningAiGovernanceLicense =
|
||||
aiGovernanceMergedLimit !== undefined &&
|
||||
aiGovernanceLimit > 0 &&
|
||||
aiGovernanceLimit === aiGovernanceMergedLimit;
|
||||
const canUseAiGovernanceUsageForThisLicense =
|
||||
isLicenseApplicableForAiGovernanceOverage &&
|
||||
isLicenseApplicable &&
|
||||
hasExplicitAiGovernanceAddOn &&
|
||||
isWinningAiGovernanceLicense;
|
||||
// Show the add-on as exceeded only for the winning, active add-on license.
|
||||
|
||||
@@ -82,6 +82,9 @@ const LicensesSettingsPage: FC = () => {
|
||||
showConfetti={confettiOn}
|
||||
isLoading={isLoading}
|
||||
isRefreshing={refreshEntitlementsMutation.isPending}
|
||||
hasUserLimitEntitlementData={
|
||||
entitlementsQuery.data?.features.user_limit !== undefined
|
||||
}
|
||||
userLimitActual={entitlementsQuery.data?.features.user_limit?.actual}
|
||||
userLimitLimit={entitlementsQuery.data?.features.user_limit?.limit}
|
||||
licenses={licenses}
|
||||
|
||||
+65
-18
@@ -1,28 +1,75 @@
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { MockLicenseResponse } from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Feature } from "api/typesGenerated";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import LicensesSettingsPageView from "./LicensesSettingsPageView";
|
||||
|
||||
export default {
|
||||
const meta: Meta<typeof LicensesSettingsPageView> = {
|
||||
title: "pages/DeploymentSettingsPage/LicensesSettingsPageView",
|
||||
parameters: { chromatic },
|
||||
component: LicensesSettingsPageView,
|
||||
};
|
||||
|
||||
const defaultArgs = {
|
||||
showConfetti: false,
|
||||
isLoading: false,
|
||||
userLimitActual: 1,
|
||||
userLimitLimit: 10,
|
||||
licenses: MockLicenseResponse,
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: defaultArgs,
|
||||
};
|
||||
|
||||
export const Empty = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
licenses: null,
|
||||
showConfetti: false,
|
||||
isLoading: false,
|
||||
hasUserLimitEntitlementData: true,
|
||||
userLimitActual: 1,
|
||||
userLimitLimit: 10,
|
||||
licenses: MockLicenseResponse,
|
||||
isRemovingLicense: false,
|
||||
isRefreshing: false,
|
||||
removeLicense: fn(),
|
||||
refreshEntitlements: fn(),
|
||||
activeUsers: [{ date: "2024-01-01", count: 1 }],
|
||||
managedAgentFeature: {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
} satisfies Feature,
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
} satisfies Feature,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LicensesSettingsPageView>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
licenses: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** Premium + AI governance usage bars; AI governance shows `SeatUsageBarCard` (not the not-entitled placeholder). */
|
||||
export const ActiveAIGovernanceAddOnUsage: Story = {
|
||||
args: {
|
||||
userLimitActual: 1923,
|
||||
userLimitLimit: 2500,
|
||||
activeUsers: [
|
||||
{ date: "2024-01-01", count: 100 },
|
||||
{ date: "2024-02-01", count: 120 },
|
||||
],
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
limit: 1000,
|
||||
actual: 512,
|
||||
} satisfies Feature,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("heading", { name: "Seat usage" }),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByText("1,923")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("2,500")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("heading", { name: "AI governance add-on usage" }),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByText("512")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("1,000")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
+30
-17
@@ -20,15 +20,17 @@ import { useWindowSize } from "hooks/useWindowSize";
|
||||
import { PlusIcon, RotateCwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import Confetti from "react-confetti";
|
||||
import { Link } from "react-router";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { AIGovernanceUsersConsumption } from "./AIGovernanceUsersConsumptionChart";
|
||||
import { LicenseCard } from "./LicenseCard";
|
||||
import { LicenseSeatConsumptionChart } from "./LicenseSeatConsumptionChart";
|
||||
import { ManagedAgentsConsumption } from "./ManagedAgentsConsumption";
|
||||
import { SeatUsageBarCard } from "./SeatUsageBarCard";
|
||||
|
||||
type Props = {
|
||||
showConfetti: boolean;
|
||||
isLoading: boolean;
|
||||
hasUserLimitEntitlementData: boolean;
|
||||
userLimitActual?: number;
|
||||
userLimitLimit?: number;
|
||||
licenses?: GetLicensesResponse[];
|
||||
@@ -44,6 +46,7 @@ type Props = {
|
||||
const LicensesSettingsPageView: FC<Props> = ({
|
||||
showConfetti,
|
||||
isLoading,
|
||||
hasUserLimitEntitlementData,
|
||||
userLimitActual,
|
||||
userLimitLimit,
|
||||
licenses,
|
||||
@@ -82,10 +85,10 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/deployment/licenses/add">
|
||||
<RouterLink to="/deployment/licenses/add">
|
||||
<PlusIcon />
|
||||
Add a license
|
||||
</Link>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -156,25 +159,35 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{licenses && licenses.length > 0 && (
|
||||
<LicenseSeatConsumptionChart
|
||||
limit={userLimitLimit}
|
||||
data={activeUsers?.map((i) => ({
|
||||
date: i.date,
|
||||
users: i.count,
|
||||
limit: 80,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{licenses && licenses.length > 0 && (
|
||||
<>
|
||||
<LicenseSeatConsumptionChart
|
||||
limit={userLimitLimit}
|
||||
data={activeUsers?.map((i) => ({
|
||||
date: i.date,
|
||||
users: i.count,
|
||||
limit: 80,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{hasUserLimitEntitlementData && (
|
||||
<SeatUsageBarCard
|
||||
title="Seat usage"
|
||||
actual={userLimitActual}
|
||||
limit={userLimitLimit}
|
||||
allowUnlimited
|
||||
/>
|
||||
)}
|
||||
<AIGovernanceUsersConsumption
|
||||
aiGovernanceUserFeature={aiGovernanceUserFeature}
|
||||
licenses={licenses}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ManagedAgentsConsumption
|
||||
managedAgentFeature={managedAgentFeature}
|
||||
/>
|
||||
<AIGovernanceUsersConsumption
|
||||
aiGovernanceUserFeature={aiGovernanceUserFeature}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { SeatUsageBarCard } from "./SeatUsageBarCard";
|
||||
|
||||
const meta: Meta<typeof SeatUsageBarCard> = {
|
||||
title: "pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard",
|
||||
component: SeatUsageBarCard,
|
||||
args: {
|
||||
title: "Seat usage",
|
||||
actual: 1923,
|
||||
limit: 2500,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SeatUsageBarCard>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const NearLimit: Story = {
|
||||
args: {
|
||||
actual: 2400,
|
||||
limit: 2500,
|
||||
},
|
||||
};
|
||||
|
||||
export const OverLimit: Story = {
|
||||
args: {
|
||||
actual: 2600,
|
||||
limit: 2500,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("2,600")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("2,500")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingActual: Story = {
|
||||
args: {
|
||||
actual: undefined,
|
||||
limit: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorInvalidLimit: Story = {
|
||||
args: {
|
||||
actual: 100,
|
||||
limit: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const Unlimited: Story = {
|
||||
args: {
|
||||
actual: 1923,
|
||||
limit: undefined,
|
||||
allowUnlimited: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,923")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Unlimited")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
type SeatUsageBarCardProps = {
|
||||
title: string;
|
||||
actual: number | undefined;
|
||||
limit: number | undefined;
|
||||
allowUnlimited?: boolean;
|
||||
};
|
||||
|
||||
export const SeatUsageBarCard: FC<SeatUsageBarCardProps> = ({
|
||||
title,
|
||||
actual,
|
||||
limit,
|
||||
allowUnlimited = false,
|
||||
}) => {
|
||||
const isUnlimited = allowUnlimited && limit === undefined;
|
||||
|
||||
if (!isUnlimited && (limit === undefined || 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 activeNum = actual ?? 0;
|
||||
const isExceeded =
|
||||
!isUnlimited && actual !== undefined && actual > meteredLimit;
|
||||
const usagePercentage = isUnlimited
|
||||
? 100
|
||||
: meteredLimit > 0
|
||||
? Math.min((activeNum / meteredLimit) * 100, 100)
|
||||
: 0;
|
||||
|
||||
const activeLabel =
|
||||
actual === undefined ? "—" : activeNum.toLocaleString("en-US");
|
||||
const limitLabel = isUnlimited
|
||||
? "Unlimited"
|
||||
: meteredLimit.toLocaleString("en-US");
|
||||
|
||||
return (
|
||||
<section className={cn("border border-solid rounded")}>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-md m-0 font-medium">{title}</h3>
|
||||
|
||||
<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",
|
||||
isExceeded ? "bg-highlight-red" : "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">Active: </span>
|
||||
<span
|
||||
className={cn({
|
||||
"text-content-destructive": isExceeded,
|
||||
})}
|
||||
>
|
||||
{activeLabel}
|
||||
</span>
|
||||
</p>
|
||||
<p className="m-0 text-content-secondary">
|
||||
Limit: <span className="text-content-primary">{limitLabel}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user