mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat(site/src/pages/AgentsPage/components): show workspace quota in usage indicator (#25168)
This updates the Agents sidebar usage indicator to surface workspace quota alongside AI spend limits. When both signals are active, the compact trigger renders stacked bars in the same order as the dropdown instead of collapsing them into a single percent. The dropdown still shows the full labels, percentages, and details for each usage section, and Storybook coverage now exercises the combined sidebar state. <img width="315" height="261" alt="image" src="https://github.com/user-attachments/assets/e5cfc276-2cc0-4dc9-9400-6d1b829e75e2" /> <img width="320" height="243" alt="image" src="https://github.com/user-attachments/assets/506ae8ad-3d93-4857-9cdb-b3cf4142772d" /> <img width="314" height="353" alt="image" src="https://github.com/user-attachments/assets/5af3644f-f155-43a4-bae9-91b33a0a4333" /> <img width="322" height="349" alt="image" src="https://github.com/user-attachments/assets/9ae4ae55-55aa-4a2f-856e-f462793f389e" /> Relates to CODAGT-197
This commit is contained in:
@@ -1,30 +1,115 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { FC } from "react";
|
||||
import { useQueryClient } from "react-query";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { chatUsageLimitStatusKey } from "#/api/queries/chats";
|
||||
import { getWorkspaceQuotaQueryKey } from "#/api/queries/workspaceQuota";
|
||||
import { workspacesKey } from "#/api/queries/workspaces";
|
||||
import type {
|
||||
ChatUsageLimitStatus,
|
||||
WorkspaceQuota,
|
||||
WorkspacesResponse,
|
||||
} from "#/api/typesGenerated";
|
||||
import {
|
||||
MockDefaultOrganization,
|
||||
MockPermissions,
|
||||
MockUserOwner,
|
||||
} from "#/testHelpers/entities";
|
||||
import {
|
||||
withAuthProvider,
|
||||
withDashboardProvider,
|
||||
} from "#/testHelpers/storybook";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
|
||||
const withUsageLimitStatus =
|
||||
(status: {
|
||||
is_limited: boolean;
|
||||
period?: "day" | "week" | "month";
|
||||
spend_limit_micros?: number;
|
||||
current_spend: number;
|
||||
period_start?: string;
|
||||
period_end?: string;
|
||||
}) =>
|
||||
(Story: FC) => {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(chatUsageLimitStatusKey, status);
|
||||
return <Story />;
|
||||
};
|
||||
const withUsageLimitStatus = (status: ChatUsageLimitStatus) => (Story: FC) => {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(chatUsageLimitStatusKey, status);
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
const periodStart = new Date().toISOString();
|
||||
const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const withWorkspaceQuota = (quota: WorkspaceQuota) => (Story: FC) => {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(
|
||||
getWorkspaceQuotaQueryKey(
|
||||
MockDefaultOrganization.name,
|
||||
MockUserOwner.username,
|
||||
),
|
||||
quota,
|
||||
);
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
const withWorkspaceCount = (count: number) => (Story: FC) => {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(workspacesKey(userWorkspacesRequest), {
|
||||
workspaces: [],
|
||||
count,
|
||||
} satisfies WorkspacesResponse);
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
const withUnavailableWorkspaceCount = (Story: FC) => {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(workspacesKey(userWorkspacesRequest), {
|
||||
workspaces: [],
|
||||
count: -1,
|
||||
} satisfies WorkspacesResponse);
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
const withUsageIndicatorFrame = (Story: FC) => (
|
||||
<div className="flex h-12 w-[260px] items-stretch justify-end rounded-md bg-surface-secondary">
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
|
||||
const openUsageMenu = async (canvasElement: HTMLElement) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
};
|
||||
|
||||
const limitedUsageStatus = (
|
||||
overrides: Partial<ChatUsageLimitStatus> = {},
|
||||
): ChatUsageLimitStatus => ({
|
||||
is_limited: true,
|
||||
period: "month",
|
||||
spend_limit_micros: 50_000_000,
|
||||
current_spend: 12_500_000,
|
||||
period_start: "2026-02-10T00:00:00Z",
|
||||
period_end: "2026-03-12T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const unlimitedUsageStatus = {
|
||||
is_limited: false,
|
||||
current_spend: 0,
|
||||
} satisfies ChatUsageLimitStatus;
|
||||
|
||||
const userWorkspacesRequest = {
|
||||
q: `owner:me organization:${MockDefaultOrganization.name}`,
|
||||
limit: 0,
|
||||
};
|
||||
const noWorkspaceQuota = {
|
||||
credits_consumed: 0,
|
||||
budget: 0,
|
||||
} satisfies WorkspaceQuota;
|
||||
const defaultWorkspaceQuota = {
|
||||
credits_consumed: 30,
|
||||
budget: 100,
|
||||
} satisfies WorkspaceQuota;
|
||||
|
||||
const meta: Meta<typeof UsageIndicator> = {
|
||||
title: "pages/AgentsPage/UsageIndicator",
|
||||
component: UsageIndicator,
|
||||
decorators: [
|
||||
withAuthProvider,
|
||||
withDashboardProvider,
|
||||
withUsageIndicatorFrame,
|
||||
],
|
||||
parameters: {
|
||||
user: MockUserOwner,
|
||||
permissions: MockPermissions,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -32,61 +117,148 @@ type Story = StoryObj<typeof UsageIndicator>;
|
||||
|
||||
export const LowUsage: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus({
|
||||
is_limited: true,
|
||||
period: "month",
|
||||
spend_limit_micros: 50_000_000,
|
||||
current_spend: 12_500_000,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
}),
|
||||
withUsageLimitStatus(limitedUsageStatus()),
|
||||
withWorkspaceQuota(noWorkspaceQuota),
|
||||
],
|
||||
};
|
||||
|
||||
export const MediumUsage: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus({
|
||||
is_limited: true,
|
||||
period: "week",
|
||||
spend_limit_micros: 20_000_000,
|
||||
current_spend: 16_000_000,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
}),
|
||||
withUsageLimitStatus(
|
||||
limitedUsageStatus({
|
||||
period: "week",
|
||||
spend_limit_micros: 20_000_000,
|
||||
current_spend: 16_000_000,
|
||||
}),
|
||||
),
|
||||
withWorkspaceQuota(noWorkspaceQuota),
|
||||
],
|
||||
};
|
||||
|
||||
export const HighUsage: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus({
|
||||
is_limited: true,
|
||||
period: "day",
|
||||
spend_limit_micros: 10_000_000,
|
||||
current_spend: 9_500_000,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
}),
|
||||
withUsageLimitStatus(
|
||||
limitedUsageStatus({
|
||||
period: "day",
|
||||
spend_limit_micros: 10_000_000,
|
||||
current_spend: 9_500_000,
|
||||
}),
|
||||
),
|
||||
withWorkspaceQuota(noWorkspaceQuota),
|
||||
],
|
||||
};
|
||||
|
||||
export const LimitExceeded: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus({
|
||||
is_limited: true,
|
||||
period: "month",
|
||||
spend_limit_micros: 30_000_000,
|
||||
current_spend: 32_000_000,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
withUsageLimitStatus(
|
||||
limitedUsageStatus({
|
||||
spend_limit_micros: 30_000_000,
|
||||
current_spend: 32_000_000,
|
||||
}),
|
||||
),
|
||||
withWorkspaceQuota(noWorkspaceQuota),
|
||||
],
|
||||
};
|
||||
|
||||
export const WorkspaceQuotaOnly: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus(unlimitedUsageStatus),
|
||||
withWorkspaceQuota(defaultWorkspaceQuota),
|
||||
withWorkspaceCount(3),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
await openUsageMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
export const UsageAndWorkspaceQuota: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus(limitedUsageStatus()),
|
||||
withWorkspaceQuota(defaultWorkspaceQuota),
|
||||
withWorkspaceCount(3),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const progressBars = canvas.getAllByRole("progressbar");
|
||||
|
||||
expect(canvas.getByText("Usage")).toBeInTheDocument();
|
||||
expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([
|
||||
"Monthly spend usage",
|
||||
"Workspace quota usage",
|
||||
]);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceQuotaUnused: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus(unlimitedUsageStatus),
|
||||
withWorkspaceQuota({
|
||||
credits_consumed: 0,
|
||||
budget: 100,
|
||||
}),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(canvas.queryByRole("button")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceQuotaWithoutBudget: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus(unlimitedUsageStatus),
|
||||
withWorkspaceQuota({
|
||||
credits_consumed: 20,
|
||||
budget: 0,
|
||||
}),
|
||||
withWorkspaceCount(1),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const progressbar = canvas.getByRole("progressbar", {
|
||||
name: "Workspace quota usage",
|
||||
});
|
||||
|
||||
expect(canvas.getByText("Workspace quota")).toBeInTheDocument();
|
||||
expect(progressbar).toHaveAttribute("aria-valuenow", "100");
|
||||
|
||||
await openUsageMenu(canvasElement);
|
||||
expect(within(document.body).getByText("100%")).toBeInTheDocument();
|
||||
expect(
|
||||
within(document.body).getByText("1 workspace using 20 of 0 credits"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceQuotaExceeded: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus(unlimitedUsageStatus),
|
||||
withWorkspaceQuota({
|
||||
credits_consumed: 125,
|
||||
budget: 100,
|
||||
}),
|
||||
withWorkspaceCount(7),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
await openUsageMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceQuotaWithoutWorkspaceCount: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus(unlimitedUsageStatus),
|
||||
withWorkspaceQuota(defaultWorkspaceQuota),
|
||||
withUnavailableWorkspaceCount,
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
await openUsageMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
export const NotLimited: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus({
|
||||
is_limited: false,
|
||||
current_spend: 0,
|
||||
}),
|
||||
withUsageLimitStatus(unlimitedUsageStatus),
|
||||
withWorkspaceQuota(noWorkspaceQuota),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { FC } from "react";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { type FC, Fragment, type ReactNode } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link } from "react-router";
|
||||
import { chatUsageLimitStatus } from "#/api/queries/chats";
|
||||
import { workspaceQuota } from "#/api/queries/workspaceQuota";
|
||||
import { workspaces } from "#/api/queries/workspaces";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -10,94 +13,142 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatCostMicros } from "#/utils/currency";
|
||||
import { getUsageLimitPeriodLabel } from "./ChatCostSummaryView";
|
||||
|
||||
export const UsageIndicator: FC = () => {
|
||||
const { data, isLoading, isError } = useQuery(chatUsageLimitStatus());
|
||||
type UsageSeverity = "normal" | "warning" | "exceeded";
|
||||
|
||||
if (isLoading || isError || !data?.is_limited) {
|
||||
type UsageSectionData = {
|
||||
id: string;
|
||||
title: string;
|
||||
progressLabel: string;
|
||||
percent: number;
|
||||
detail: ReactNode;
|
||||
secondaryDetail?: ReactNode;
|
||||
tooltip?: ReactNode;
|
||||
severity?: UsageSeverity;
|
||||
};
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("en-US");
|
||||
|
||||
export const UsageIndicator: FC = () => {
|
||||
const { data: chatUsage, isError: isChatUsageError } = useQuery(
|
||||
chatUsageLimitStatus(),
|
||||
);
|
||||
const { user } = useAuthenticated();
|
||||
const { organizations } = useDashboard();
|
||||
const organizationName =
|
||||
organizations.find((org) => org.is_default)?.name ?? "";
|
||||
const username = user.username;
|
||||
const { data: quota, isError: isQuotaError } = useQuery({
|
||||
...workspaceQuota(organizationName, username),
|
||||
enabled: organizationName !== "" && username !== "",
|
||||
});
|
||||
const hasWorkspaceQuotaUsage =
|
||||
quota !== undefined && quota.budget >= 0 && quota.credits_consumed > 0;
|
||||
const workspacesQuery = useQuery({
|
||||
...workspaces({
|
||||
q: `owner:me organization:${organizationName}`,
|
||||
limit: 0,
|
||||
}),
|
||||
enabled: hasWorkspaceQuotaUsage && organizationName !== "",
|
||||
});
|
||||
const sections: UsageSectionData[] = [];
|
||||
|
||||
if (!isChatUsageError && chatUsage?.is_limited) {
|
||||
const spendLimit = chatUsage.spend_limit_micros ?? 0;
|
||||
const currentSpend = chatUsage.current_spend;
|
||||
const periodLabel = getUsageLimitPeriodLabel(chatUsage.period);
|
||||
const exceeded = spendLimit > 0 && currentSpend >= spendLimit;
|
||||
|
||||
sections.push({
|
||||
id: "ai-usage",
|
||||
title: `${periodLabel} Usage`,
|
||||
progressLabel: `${periodLabel} spend usage`,
|
||||
percent: getPercent(currentSpend, spendLimit),
|
||||
severity: getSeverity(currentSpend, spendLimit),
|
||||
detail: (
|
||||
<>
|
||||
{formatCostMicros(currentSpend)} of {formatCostMicros(spendLimit)}{" "}
|
||||
used
|
||||
{exceeded && (
|
||||
<span className="ml-1 text-content-destructive">
|
||||
(limit exceeded)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
secondaryDetail: chatUsage.period_end
|
||||
? `Resets ${dayjs(chatUsage.period_end).format("MMM D, YYYY")}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isQuotaError && hasWorkspaceQuotaUsage) {
|
||||
const creditsConsumed = quota.credits_consumed;
|
||||
const workspaceCount = workspacesQuery.isError
|
||||
? undefined
|
||||
: getWorkspaceCount(workspacesQuery.data?.count);
|
||||
const quotaDetail =
|
||||
workspaceCount === undefined
|
||||
? `${formatNumber(creditsConsumed)} of ${formatNumber(quota.budget)} credits used`
|
||||
: `${formatNumber(workspaceCount)} ${workspaceCount === 1 ? "workspace" : "workspaces"} using ${formatNumber(creditsConsumed)} of ${formatNumber(quota.budget)} credits`;
|
||||
|
||||
sections.push({
|
||||
id: "workspace-quota",
|
||||
title: "Workspace quota",
|
||||
progressLabel: "Workspace quota usage",
|
||||
percent: getPercent(creditsConsumed, quota.budget),
|
||||
severity: getSeverity(creditsConsumed, quota.budget),
|
||||
detail: quotaDetail,
|
||||
tooltip:
|
||||
"Workspaces, stopped or running, may consume credits. Stop or delete unused ones to free quota.",
|
||||
});
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spendLimit = data.spend_limit_micros ?? 0;
|
||||
const currentSpend = data.current_spend;
|
||||
const percent =
|
||||
spendLimit > 0 ? Math.min((currentSpend / spendLimit) * 100, 100) : 0;
|
||||
const roundedPercent = Math.round(percent);
|
||||
const exceeded = spendLimit > 0 && currentSpend >= spendLimit;
|
||||
const periodLabel = getUsageLimitPeriodLabel(data.period);
|
||||
return <UsageMenu sections={sections} />;
|
||||
};
|
||||
|
||||
const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({
|
||||
sections,
|
||||
}) => {
|
||||
const triggerLabel =
|
||||
sections.length > 1 ? "Usage" : (sections[0]?.title ?? "Usage");
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex self-stretch flex-col justify-center items-start gap-1 border-none bg-transparent px-3 cursor-pointer select-none transition-colors text-content-secondary hover:bg-surface-tertiary/50 outline-none text-[13px]"
|
||||
className="ml-auto flex self-stretch flex-col items-center justify-center gap-1 border-none bg-transparent px-3 cursor-pointer select-none transition-colors text-content-secondary hover:bg-surface-tertiary/50 outline-none text-[13px]"
|
||||
>
|
||||
<span className="shrink-0 whitespace-nowrap">
|
||||
{periodLabel} Usage
|
||||
<span className="shrink-0 whitespace-nowrap text-center">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={`${periodLabel} spend usage`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={roundedPercent}
|
||||
className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary shrink-0"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-content-secondary transition-all duration-300 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<UsageTriggerProgress sections={sections} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="min-w-auto w-[240px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-sm font-medium text-content-primary">
|
||||
{periodLabel} Usage
|
||||
</span>
|
||||
<span className="text-xs text-content-secondary">
|
||||
{roundedPercent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="px-2 pb-2">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={`${periodLabel} spend usage`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={roundedPercent}
|
||||
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-content-secondary transition-all duration-300 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spend detail */}
|
||||
<div className="px-2 pb-1.5 text-xs text-content-secondary">
|
||||
{formatCostMicros(currentSpend)} of {formatCostMicros(spendLimit)}{" "}
|
||||
used
|
||||
{exceeded && (
|
||||
<span className="ml-1 text-content-destructive">
|
||||
— limit exceeded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.period_end && (
|
||||
<div className="px-2 pb-2 text-xs text-content-secondary">
|
||||
Resets {dayjs(data.period_end).format("MMM D, YYYY")}
|
||||
</div>
|
||||
)}
|
||||
{sections.map((section, index) => (
|
||||
<Fragment key={section.id}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<UsageSection section={section} />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -108,3 +159,192 @@ export const UsageIndicator: FC = () => {
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const UsageTriggerProgress: FC<{ sections: readonly UsageSectionData[] }> = ({
|
||||
sections,
|
||||
}) => {
|
||||
const size = sections.length > 1 ? "compact" : "default";
|
||||
|
||||
return (
|
||||
<div className="flex w-24 shrink-0 flex-col gap-0.5">
|
||||
{sections.map((section) => (
|
||||
<UsageProgress
|
||||
key={section.id}
|
||||
ariaLabel={section.progressLabel}
|
||||
percent={section.percent}
|
||||
severity={section.severity}
|
||||
size={size}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsageSection: FC<{ section: UsageSectionData }> = ({ section }) => {
|
||||
const roundedPercent = Math.round(section.percent);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<span className="truncate text-sm font-medium text-content-primary">
|
||||
{section.title}
|
||||
</span>
|
||||
<span
|
||||
className={cn("shrink-0 text-xs", getTextClassName(section.severity))}
|
||||
>
|
||||
{roundedPercent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-2">
|
||||
<UsageProgress
|
||||
ariaLabel={section.progressLabel}
|
||||
percent={section.percent}
|
||||
severity={section.severity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 text-xs leading-5 text-content-secondary",
|
||||
section.secondaryDetail ? "pb-1.5" : "pb-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="min-w-0 flex-1">{section.detail}</span>
|
||||
{section.tooltip && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 inline-flex size-3.5 shrink-0 cursor-help items-center justify-center rounded-sm border-none bg-transparent p-0 text-content-secondary/70 outline-none transition-colors hover:text-content-primary focus-visible:ring-2 focus-visible:ring-content-link"
|
||||
aria-label={`${section.title} help`}
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={4}
|
||||
className="max-w-48 text-xs"
|
||||
>
|
||||
{section.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{section.secondaryDetail && (
|
||||
<div className="px-2 pb-2 text-xs text-content-secondary">
|
||||
{section.secondaryDetail}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UsageProgress: FC<{
|
||||
ariaLabel: string;
|
||||
percent: number;
|
||||
severity?: UsageSeverity;
|
||||
size?: "default" | "compact";
|
||||
className?: string;
|
||||
}> = ({
|
||||
ariaLabel,
|
||||
percent,
|
||||
severity = "normal",
|
||||
size = "default",
|
||||
className,
|
||||
}) => {
|
||||
const clampedPercent = clampPercent(percent);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(clampedPercent)}
|
||||
className={cn(
|
||||
size === "compact" ? "h-1" : "h-1.5",
|
||||
"overflow-hidden rounded-full bg-surface-tertiary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-300 ease-out",
|
||||
getProgressClassName(severity),
|
||||
)}
|
||||
style={{ width: `${clampedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getPercent(used: number, budget: number): number {
|
||||
if (!Number.isFinite(used) || !Number.isFinite(budget) || budget < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (budget === 0) {
|
||||
return used > 0 ? 100 : 0;
|
||||
}
|
||||
return clampPercent((used / budget) * 100);
|
||||
}
|
||||
|
||||
function clampPercent(percent: number): number {
|
||||
if (!Number.isFinite(percent)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(Math.max(percent, 0), 100);
|
||||
}
|
||||
|
||||
function getSeverity(used: number, budget: number): UsageSeverity {
|
||||
if (!Number.isFinite(used) || !Number.isFinite(budget) || budget < 0) {
|
||||
return "normal";
|
||||
}
|
||||
if (budget === 0) {
|
||||
return used > 0 ? "exceeded" : "normal";
|
||||
}
|
||||
if (used >= budget) {
|
||||
return "exceeded";
|
||||
}
|
||||
return used / budget >= 0.85 ? "warning" : "normal";
|
||||
}
|
||||
|
||||
function getProgressClassName(severity: UsageSeverity): string {
|
||||
switch (severity) {
|
||||
case "exceeded":
|
||||
return "bg-content-destructive";
|
||||
case "warning":
|
||||
return "bg-content-warning";
|
||||
case "normal":
|
||||
return "bg-content-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function getTextClassName(severity: UsageSeverity = "normal"): string {
|
||||
switch (severity) {
|
||||
case "exceeded":
|
||||
return "text-content-destructive";
|
||||
case "warning":
|
||||
return "text-content-warning";
|
||||
case "normal":
|
||||
return "text-content-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkspaceCount(count: number | undefined): number | undefined {
|
||||
if (count === undefined || !Number.isFinite(count) || count < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user