mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: prettify agents usage indicator (#25428)
Restyles the Agents page usage trigger to match the new quota meter presentation by Tracy. The trigger now shows one compact row per section with the existing severity colors, the same spend icon used in settings, a server icon for workspace quota, and right-aligned counters. The related stories were updated to reflect the new trigger layout. **Before:** <img width="318" height="332" alt="image" src="https://github.com/user-attachments/assets/4c6087bd-7c14-4cb6-b2e7-26bb7a1d3e70" /> **After:** <img width="323" height="353" alt="image" src="https://github.com/user-attachments/assets/95bfb992-cab8-473e-838d-1dcbe246fe3d" /> Relates to CODAGT-197
This commit is contained in:
@@ -16,11 +16,13 @@ export const UserSidebarFooter: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="hidden border-0 border-t border-solid sm:block">
|
||||
<div className="flex items-stretch">
|
||||
{/* This footer is resizable, so child sizing must follow its container width instead of the viewport. */}
|
||||
<div className="flex min-w-0 items-stretch [container-type:inline-size]">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Account menu for ${user.name || user.username}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 bg-transparent border-0 cursor-pointer px-3 py-3 text-left hover:bg-surface-tertiary/50 transition-colors"
|
||||
>
|
||||
<Avatar
|
||||
@@ -28,7 +30,7 @@ export const UserSidebarFooter: FC = () => {
|
||||
src={user.avatar_url}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-content-secondary">
|
||||
{user.name || user.username}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { FC } from "react";
|
||||
import { useQueryClient } from "react-query";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
@@ -57,11 +57,22 @@ const withUnavailableWorkspaceCount = (Story: FC) => {
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
const withUsageIndicatorFrame = (Story: FC) => (
|
||||
<div className="flex h-12 w-[260px] items-stretch justify-end rounded-md bg-surface-secondary">
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
// Mirrors the sidebar footer wrapper: a fixed-width container with
|
||||
// container-type set so the trigger inside reacts to the wrapper's width
|
||||
// instead of the viewport's.
|
||||
const withUsageIndicatorFrame = (
|
||||
widthClassName = "w-[320px]",
|
||||
frameTestId?: string,
|
||||
): Decorator => {
|
||||
return (Story) => (
|
||||
<div
|
||||
data-testid={frameTestId}
|
||||
className={`flex h-12 min-w-0 items-stretch justify-end rounded-md bg-surface-secondary [container-type:inline-size] ${widthClassName}`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openUsageMenu = async (canvasElement: HTMLElement) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -104,7 +115,7 @@ const meta: Meta<typeof UsageIndicator> = {
|
||||
decorators: [
|
||||
withAuthProvider,
|
||||
withDashboardProvider,
|
||||
withUsageIndicatorFrame,
|
||||
withUsageIndicatorFrame(),
|
||||
],
|
||||
parameters: {
|
||||
user: MockUserOwner,
|
||||
@@ -167,6 +178,9 @@ export const WorkspaceQuotaOnly: Story = {
|
||||
withWorkspaceCount(3),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(canvas.getByText("30/100")).toBeVisible();
|
||||
await openUsageMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
@@ -181,12 +195,44 @@ export const UsageAndWorkspaceQuota: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
const progressBars = canvas.getAllByRole("progressbar");
|
||||
|
||||
expect(canvas.getByText("Usage")).toBeInTheDocument();
|
||||
expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible();
|
||||
expect(canvas.getByText("$12.50")).toBeVisible();
|
||||
expect(canvas.getByText("30/100")).toBeVisible();
|
||||
expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([
|
||||
"Monthly spend usage",
|
||||
"Workspace quota usage",
|
||||
]);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
await openUsageMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
// The tiny story covers the responsive edge case where the trigger keeps
|
||||
// the bars visible and drops the numeric details.
|
||||
const expectTriggerContentFits = (canvasElement: HTMLElement) => {
|
||||
const canvas = within(canvasElement);
|
||||
const frame = canvas.getByTestId("usage-indicator-frame");
|
||||
|
||||
expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible();
|
||||
expect(frame.scrollWidth).toBeLessThanOrEqual(frame.clientWidth);
|
||||
};
|
||||
|
||||
const expectTriggerDetailsHidden = (canvasElement: HTMLElement) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(canvas.getByText("$12.50")).not.toBeVisible();
|
||||
expect(canvas.getByText("30/100")).not.toBeVisible();
|
||||
};
|
||||
|
||||
export const TriggerTiny: Story = {
|
||||
decorators: [
|
||||
withUsageIndicatorFrame("w-[240px]", "usage-indicator-frame"),
|
||||
withUsageLimitStatus(limitedUsageStatus()),
|
||||
withWorkspaceQuota(defaultWorkspaceQuota),
|
||||
withWorkspaceCount(3),
|
||||
],
|
||||
play: ({ canvasElement }) => {
|
||||
expectTriggerContentFits(canvasElement);
|
||||
expectTriggerDetailsHidden(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -220,7 +266,7 @@ export const WorkspaceQuotaWithoutBudget: Story = {
|
||||
name: "Workspace quota usage",
|
||||
});
|
||||
|
||||
expect(canvas.getByText("Workspace quota")).toBeInTheDocument();
|
||||
expect(canvas.getByText("20")).toBeInTheDocument();
|
||||
expect(progressbar).toHaveAttribute("aria-valuenow", "100");
|
||||
|
||||
await openUsageMenu(canvasElement);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { CoinsIcon, InfoIcon, ServerIcon } from "lucide-react";
|
||||
import { type FC, Fragment, type ReactNode } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link } from "react-router";
|
||||
@@ -36,6 +36,8 @@ type UsageSectionData = {
|
||||
progressLabel: string;
|
||||
percent: number;
|
||||
detail: ReactNode;
|
||||
icon: ReactNode;
|
||||
summaryValue: string;
|
||||
secondaryDetail?: ReactNode;
|
||||
tooltip?: ReactNode;
|
||||
severity?: UsageSeverity;
|
||||
@@ -79,6 +81,8 @@ export const UsageIndicator: FC = () => {
|
||||
progressLabel: `${periodLabel} spend usage`,
|
||||
percent: getPercent(currentSpend, spendLimit),
|
||||
severity: getSeverity(currentSpend, spendLimit),
|
||||
icon: <CoinsIcon className="size-3.5" />,
|
||||
summaryValue: formatCostMicros(currentSpend),
|
||||
detail: (
|
||||
<>
|
||||
{formatCostMicros(currentSpend)} of {formatCostMicros(spendLimit)}{" "}
|
||||
@@ -112,6 +116,11 @@ export const UsageIndicator: FC = () => {
|
||||
progressLabel: "Workspace quota usage",
|
||||
percent: getPercent(creditsConsumed, quota.budget),
|
||||
severity: getSeverity(creditsConsumed, quota.budget),
|
||||
icon: <ServerIcon className="size-3.5" />,
|
||||
summaryValue:
|
||||
quota.budget > 0
|
||||
? `${formatNumber(creditsConsumed)}/${formatNumber(quota.budget)}`
|
||||
: formatNumber(creditsConsumed),
|
||||
detail: quotaDetail,
|
||||
tooltip:
|
||||
"Workspaces, stopped or running, may consume credits. Stop or delete unused ones to free quota.",
|
||||
@@ -128,7 +137,7 @@ export const UsageIndicator: FC = () => {
|
||||
const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({
|
||||
sections,
|
||||
}) => {
|
||||
const triggerLabel =
|
||||
const triggerAriaLabel =
|
||||
sections.length > 1 ? "Usage" : (sections[0]?.title ?? "Usage");
|
||||
|
||||
return (
|
||||
@@ -136,11 +145,9 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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]"
|
||||
aria-label={triggerAriaLabel}
|
||||
className="flex shrink-0 self-stretch items-center justify-center border-none bg-transparent px-3 cursor-pointer select-none transition-colors hover:bg-surface-tertiary/50 outline-none"
|
||||
>
|
||||
<span className="shrink-0 whitespace-nowrap text-center">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
<UsageTriggerProgress sections={sections} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -166,19 +173,35 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({
|
||||
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">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
{sections.map((section) => (
|
||||
<UsageProgress
|
||||
key={section.id}
|
||||
ariaLabel={section.progressLabel}
|
||||
percent={section.percent}
|
||||
severity={section.severity}
|
||||
size={size}
|
||||
className="w-full"
|
||||
/>
|
||||
<div key={section.id} className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center",
|
||||
getTextClassName(section.severity),
|
||||
)}
|
||||
>
|
||||
{section.icon}
|
||||
</span>
|
||||
<UsageProgress
|
||||
ariaLabel={section.progressLabel}
|
||||
percent={section.percent}
|
||||
severity={section.severity}
|
||||
size="compact"
|
||||
className="w-20 shrink-0 [@container_(min-width:300px)]:w-24 [@container_(min-width:420px)]:w-32 [@container_(min-width:560px)]:w-40"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden shrink-0 whitespace-nowrap text-xs tabular-nums [@container_(min-width:300px)]:inline",
|
||||
getTextClassName(section.severity),
|
||||
)}
|
||||
>
|
||||
{section.summaryValue}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user