Skip to content

chore: add managed ai usage consumption to license view #18934

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
6 changes: 5 additions & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,11 @@
"vite-plugin-checker": "0.9.3",
"vite-plugin-turbosnap": "1.0.3"
},
"browserslist": ["chrome 110", "firefox 111", "safari 16.0"],
"browserslist": [
"chrome 110",
"firefox 111",
"safari 16.0"
],
"resolutions": {
"optionator": "0.9.3",
"semver": "7.6.2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -32,6 +33,7 @@ type Props = {
removeLicense: (licenseId: number) => void;
refreshEntitlements: () => void;
activeUsers: UserStatusChangeCount[] | undefined;
managedAgentFeature?: Feature;
};

const LicensesSettingsPageView: FC<Props> = ({
Expand All @@ -45,9 +47,13 @@ const LicensesSettingsPageView: FC<Props> = ({
removeLicense,
refreshEntitlements,
activeUsers,
managedAgentFeature,
}) => {
const theme = useTheme();
const { width, height } = useWindowSize();
const managedAgentLimitStarts = managedAgentFeature?.usage_period?.start;
const managedAgentLimitExpires = managedAgentFeature?.usage_period?.end;
const managedAgentFeatureEnabled = managedAgentFeature?.enabled;
Comment on lines +54 to +56
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only place these variables are used is to be passed in to ManagedAgentsConsumption. Why does that component take all of the values individually rather than just taking a Feature?


return (
<>
Expand Down Expand Up @@ -151,6 +157,17 @@ const LicensesSettingsPageView: FC<Props> = ({
}))}
/>
)}

{licenses && licenses.length > 0 && managedAgentFeature && (
<ManagedAgentsConsumption
usage={managedAgentFeature.actual || 0}
included={managedAgentFeature.soft_limit || 0}
limit={managedAgentFeature.limit || 0}
startDate={managedAgentLimitStarts || ""}
endDate={managedAgentLimitExpires || ""}
enabled={managedAgentFeatureEnabled}
Comment on lines +163 to +168
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...and then we don't need to do this defaulting here. we should do some validation inside of this component and return null or an <ErrorAlert> if the feature passed in is incorrect.

/>
)}
</div>
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ManagedAgentsConsumption } from "./ManagedAgentsConsumption";

const meta: Meta<typeof ManagedAgentsConsumption> = {
title:
"pages/DeploymentSettingsPage/LicensesSettingsPage/ManagedAgentsConsumption",
component: ManagedAgentsConsumption,
args: {
usage: 50000,
included: 60000,
limit: 120000,
startDate: "February 27, 2025",
endDate: "February 27, 2026",
},
};

export default meta;
type Story = StoryObj<typeof ManagedAgentsConsumption>;

export const Default: Story = {};

export const NearLimit: Story = {
args: {
usage: 115000,
included: 60000,
limit: 120000,
},
};

export const OverIncluded: Story = {
args: {
usage: 80000,
included: 60000,
limit: 120000,
},
};

export const LowUsage: Story = {
args: {
usage: 25000,
included: 60000,
limit: 120000,
},
};

export const IncludedAtLimit: Story = {
args: {
usage: 25000,
included: 30500,
limit: 30500,
},
};

export const Disabled: Story = {
args: {
enabled: false,
usage: Number.NaN,
included: Number.NaN,
limit: Number.NaN,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import type { Interpolation, Theme } from "@emotion/react";
import MuiLink from "@mui/material/Link";
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";

interface ManagedAgentsConsumptionProps {
usage: number;
included: number;
limit: number;
startDate: string;
endDate: string;
enabled?: boolean;
}

export const ManagedAgentsConsumption: FC<ManagedAgentsConsumptionProps> = ({
usage,
included,
limit,
startDate,
endDate,
enabled = true,
}) => {
if (!enabled) {
return (
<div css={styles.disabledRoot}>
<Stack alignItems="center" spacing={1}>
<Stack alignItems="center" spacing={0.5}>
<span css={styles.disabledTitle}>
Managed AI Agents Disabled
</span>
<span css={styles.disabledDescription}>
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 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 agents consumption
</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 />
How we calculate managed agents consumption
</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>
Managed agents are counted based on the amount of successfully
started workspaces with an AI agent.
Comment on lines +86 to +87
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Managed agents are counted based on the amount of successfully
started workspaces with an AI agent.
Managed agents are counted based on the amount of running
workspaces with an AI agent.

</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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Amount of started workspaces with an AI agent.
Running workspaces with an AI agent

I feel like legend items don't usually have punctuation at the end

</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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Included allowance from your current license plan.
Allowance

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

const styles = {
disabledTitle: {
fontSize: 16,
},

disabledRoot: (theme) => ({
minHeight: 240,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
padding: 48,
}),

disabledDescription: (theme) => ({
color: theme.palette.text.secondary,
textAlign: "center",
maxWidth: 464,
marginTop: 8,
}),
} satisfies Record<string, Interpolation<Theme>>;
Loading