mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: add managed ai usage consumption to license view (#18934)
Customers with licenses not supporting managed AI agents will receive the following information: <img width="597" height="261" alt="image" src="https://github.com/user-attachments/assets/b794a9f2-bca8-494e-a8d1-cc6e6bc43bfe" /> Customers with active licenes for managed AI agents will see: <img width="604" height="293" alt="image" src="https://github.com/user-attachments/assets/7ce8931c-05c6-4e64-a5a1-2e9364e99de2" /> Closes https://github.com/coder/internal/issues/813 --------- Co-authored-by: McKayla Washburn <mckayla@hey.com>
This commit is contained in:
co-authored by
McKayla Washburn
parent
0672bf5084
commit
36d2e01471
@@ -85,6 +85,9 @@ const LicensesSettingsPage: FC = () => {
|
||||
isRemovingLicense={isRemovingLicense}
|
||||
removeLicense={(licenseId: number) => removeLicenseApi(licenseId)}
|
||||
activeUsers={userStatusCount?.active}
|
||||
managedAgentFeature={
|
||||
entitlementsQuery.data?.features.managed_agent_limit
|
||||
}
|
||||
refreshEntitlements={async () => {
|
||||
try {
|
||||
await refreshEntitlementsMutation.mutateAsync();
|
||||
|
||||
+8
-1
@@ -4,7 +4,7 @@ import MuiLink from "@mui/material/Link";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import type { GetLicensesResponse } from "api/api";
|
||||
import type { UserStatusChangeCount } from "api/typesGenerated";
|
||||
import type { Feature, UserStatusChangeCount } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
SettingsHeader,
|
||||
@@ -20,6 +20,7 @@ import Confetti from "react-confetti";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LicenseCard } from "./LicenseCard";
|
||||
import { LicenseSeatConsumptionChart } from "./LicenseSeatConsumptionChart";
|
||||
import { ManagedAgentsConsumption } from "./ManagedAgentsConsumption";
|
||||
|
||||
type Props = {
|
||||
showConfetti: boolean;
|
||||
@@ -32,6 +33,7 @@ type Props = {
|
||||
removeLicense: (licenseId: number) => void;
|
||||
refreshEntitlements: () => void;
|
||||
activeUsers: UserStatusChangeCount[] | undefined;
|
||||
managedAgentFeature?: Feature;
|
||||
};
|
||||
|
||||
const LicensesSettingsPageView: FC<Props> = ({
|
||||
@@ -45,6 +47,7 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
removeLicense,
|
||||
refreshEntitlements,
|
||||
activeUsers,
|
||||
managedAgentFeature,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { width, height } = useWindowSize();
|
||||
@@ -151,6 +154,10 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{licenses && licenses.length > 0 && (
|
||||
<ManagedAgentsConsumption managedAgentFeature={managedAgentFeature} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ManagedAgentsConsumption } from "./ManagedAgentsConsumption";
|
||||
|
||||
const meta: Meta<typeof ManagedAgentsConsumption> = {
|
||||
title:
|
||||
"pages/DeploymentSettingsPage/LicensesSettingsPage/ManagedAgentsConsumption",
|
||||
component: ManagedAgentsConsumption,
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: 50000,
|
||||
soft_limit: 60000,
|
||||
limit: 120000,
|
||||
usage_period: {
|
||||
start: "February 27, 2025",
|
||||
end: "February 27, 2026",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ManagedAgentsConsumption>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const NearLimit: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: 115000,
|
||||
soft_limit: 60000,
|
||||
limit: 120000,
|
||||
usage_period: {
|
||||
start: "February 27, 2025",
|
||||
end: "February 27, 2026",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const OverIncluded: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: 80000,
|
||||
soft_limit: 60000,
|
||||
limit: 120000,
|
||||
usage_period: {
|
||||
start: "February 27, 2025",
|
||||
end: "February 27, 2026",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LowUsage: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: 25000,
|
||||
soft_limit: 60000,
|
||||
limit: 120000,
|
||||
usage_period: {
|
||||
start: "February 27, 2025",
|
||||
end: "February 27, 2026",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const IncludedAtLimit: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: 25000,
|
||||
soft_limit: 30500,
|
||||
limit: 30500,
|
||||
usage_period: {
|
||||
start: "February 27, 2025",
|
||||
end: "February 27, 2026",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: false,
|
||||
actual: undefined,
|
||||
soft_limit: undefined,
|
||||
limit: undefined,
|
||||
usage_period: undefined,
|
||||
entitlement: "not_entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoFeature: Story = {
|
||||
args: {
|
||||
managedAgentFeature: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Error States for Validation
|
||||
export const ErrorMissingData: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: undefined,
|
||||
soft_limit: undefined,
|
||||
limit: undefined,
|
||||
usage_period: undefined,
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorNegativeValues: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: -100,
|
||||
soft_limit: 60000,
|
||||
limit: 120000,
|
||||
usage_period: {
|
||||
start: "February 27, 2025",
|
||||
end: "February 27, 2026",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorSoftLimitExceedsLimit: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: 50000,
|
||||
soft_limit: 150000,
|
||||
limit: 120000,
|
||||
usage_period: {
|
||||
start: "February 27, 2025",
|
||||
end: "February 27, 2026",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorInvalidDates: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: 50000,
|
||||
soft_limit: 60000,
|
||||
limit: 120000,
|
||||
usage_period: {
|
||||
start: "invalid-date",
|
||||
end: "February 27, 2026",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorEndBeforeStart: Story = {
|
||||
args: {
|
||||
managedAgentFeature: {
|
||||
enabled: true,
|
||||
actual: 50000,
|
||||
soft_limit: 60000,
|
||||
limit: 120000,
|
||||
usage_period: {
|
||||
start: "February 27, 2026",
|
||||
end: "February 27, 2025",
|
||||
issued_at: "February 27, 2025",
|
||||
},
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
};
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import MuiLink from "@mui/material/Link";
|
||||
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 { Stack } from "components/Stack/Stack";
|
||||
import dayjs from "dayjs";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
interface ManagedAgentsConsumptionProps {
|
||||
managedAgentFeature?: Feature;
|
||||
}
|
||||
|
||||
export const ManagedAgentsConsumption: FC<ManagedAgentsConsumptionProps> = ({
|
||||
managedAgentFeature,
|
||||
}) => {
|
||||
// If no feature is provided or it's disabled, show disabled state
|
||||
if (!managedAgentFeature?.enabled) {
|
||||
return (
|
||||
<div className="min-h-60 flex items-center justify-center rounded-lg border border-solid p-12">
|
||||
<Stack alignItems="center" spacing={1}>
|
||||
<Stack alignItems="center" spacing={0.5}>
|
||||
<span className="text-base">Managed AI Agents Disabled</span>
|
||||
<span className="text-content-secondary text-center max-w-[464px] mt-2">
|
||||
Managed AI agents are not included in your current license.
|
||||
Contact <MuiLink href="mailto:sales@coder.com">sales</MuiLink> to
|
||||
upgrade your license and unlock this feature.
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const usage = managedAgentFeature.actual;
|
||||
const included = managedAgentFeature.soft_limit;
|
||||
const limit = managedAgentFeature.limit;
|
||||
const startDate = managedAgentFeature.usage_period?.start;
|
||||
const endDate = managedAgentFeature.usage_period?.end;
|
||||
|
||||
if (!usage || usage < 0) {
|
||||
return <ErrorAlert error="Invalid usage data" />;
|
||||
}
|
||||
|
||||
if (!included || included < 0 || !limit || limit < 0) {
|
||||
return <ErrorAlert error="Invalid license usage limits" />;
|
||||
}
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return <ErrorAlert error="Missing license usage period" />;
|
||||
}
|
||||
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate);
|
||||
if (!start.isValid() || !end.isValid() || !start.isBefore(end)) {
|
||||
return <ErrorAlert error="Invalid license usage period" />;
|
||||
}
|
||||
|
||||
const usagePercentage = Math.min((usage / limit) * 100, 100);
|
||||
const includedPercentage = Math.min((included / limit) * 100, 100);
|
||||
const remainingPercentage = Math.max(100 - includedPercentage, 0);
|
||||
|
||||
return (
|
||||
<section className="border border-solid rounded">
|
||||
<div className="p-4">
|
||||
<Collapsible>
|
||||
<header className="flex flex-col gap-2 items-start">
|
||||
<h3 className="text-md m-0 font-medium">Managed AI Agents Usage</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>
|
||||
<MuiLink
|
||||
href={docs("/ai-coder/tasks")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Coder Tasks
|
||||
</MuiLink>{" "}
|
||||
and upcoming managed AI features are included in Coder Premium
|
||||
licenses during beta. Usage limits and pricing subject to change.
|
||||
</p>
|
||||
<ul>
|
||||
<li className="flex items-center gap-2">
|
||||
<div
|
||||
className="rounded-[2px] bg-highlight-green size-3 inline-block"
|
||||
aria-label="Legend for current usage in the chart"
|
||||
/>
|
||||
Amount of started workspaces with an AI agent.
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div
|
||||
className="rounded-[2px] bg-content-disabled size-3 inline-block"
|
||||
aria-label="Legend for included allowance in the chart"
|
||||
/>
|
||||
Included allowance from your current license plan.
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 inline-flex items-center justify-center"
|
||||
aria-label="Legend for total limit in the chart"
|
||||
>
|
||||
<div className="w-full border-b-1 border-t-1 border-dashed border-content-disabled" />
|
||||
</div>
|
||||
Total limit after which further AI workspace builds will be
|
||||
blocked.
|
||||
</li>
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-0 border-t border-solid">
|
||||
<div className="flex justify-between text-sm text-content-secondary mb-4">
|
||||
<span>
|
||||
{startDate ? dayjs(startDate).format("MMMM D, YYYY") : ""}
|
||||
</span>
|
||||
<span>{endDate ? dayjs(endDate).format("MMMM D, YYYY") : ""}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-6 bg-surface-secondary rounded overflow-hidden">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-highlight-green transition-all duration-300"
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 h-full bg-content-disabled opacity-30"
|
||||
style={{
|
||||
left: `${includedPercentage}%`,
|
||||
width: `${remainingPercentage}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative hidden lg:flex justify-between mt-4 text-sm">
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-content-secondary">Actual:</span>
|
||||
<span className="font-medium">{usage.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute flex flex-col items-center transform -translate-x-1/2"
|
||||
style={{
|
||||
left: `${Math.max(Math.min(includedPercentage, 90), 10)}%`,
|
||||
}}
|
||||
>
|
||||
<span className="text-content-secondary">Included:</span>
|
||||
<span className="font-medium">{included.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-content-secondary">Limit:</span>
|
||||
<span className="font-medium">{limit.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex lg:hidden flex-col gap-3 mt-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-content-secondary">Actual:</span>
|
||||
<span className="font-medium">{usage.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Included:</span>
|
||||
<span className="font-medium">{included.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-content-secondary">Limit:</span>
|
||||
<span className="font-medium">{limit.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user