mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add usage indicator to agents sidebar (#23307)
This commit is contained in:
@@ -3307,6 +3307,14 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getChatUsageLimitStatus =
|
||||
async (): Promise<TypesGen.ChatUsageLimitStatus> => {
|
||||
const response = await this.axios.get<TypesGen.ChatUsageLimitStatus>(
|
||||
"/api/experimental/chats/usage-limits/status",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateChatUsageLimitConfig = async (
|
||||
req: TypesGen.ChatUsageLimitConfig,
|
||||
): Promise<TypesGen.ChatUsageLimitConfig> => {
|
||||
|
||||
@@ -560,6 +560,17 @@ export const prInsights = (params?: {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
export const chatUsageLimitStatusKey = [
|
||||
...chatsKey,
|
||||
"usageLimitStatus",
|
||||
] as const;
|
||||
|
||||
export const chatUsageLimitStatus = () => ({
|
||||
queryKey: chatUsageLimitStatusKey,
|
||||
queryFn: () => API.getChatUsageLimitStatus(),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const chatUsageLimitConfigKey = [...chatsKey, "usageLimitConfig"] as const;
|
||||
|
||||
export const chatUsageLimitConfig = () => ({
|
||||
|
||||
@@ -72,6 +72,7 @@ import { cn } from "utils/cn";
|
||||
import { shortRelativeTime } from "utils/time";
|
||||
import { getNormalizedModelRef } from "./modelOptions";
|
||||
import { getTimeGroup, TIME_GROUPS } from "./timeGroups";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
|
||||
type SidebarView =
|
||||
| { panel: "chats" }
|
||||
@@ -898,8 +899,9 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="hidden border-0 border-t border-solid md:block">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-stretch">
|
||||
<DropdownMenu>
|
||||
{" "}
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -932,11 +934,11 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<UsageIndicator />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Panel 2: Sub-navigation (Settings) ── */}
|
||||
{/* ── Panel 2: Sub-navigation (Settings) ── */}{" "}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col transition-transform duration-200 ease-in-out",
|
||||
|
||||
@@ -25,7 +25,7 @@ interface ChatCostSummaryViewProps {
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
const getUsageLimitPeriodLabel = (
|
||||
export const getUsageLimitPeriodLabel = (
|
||||
period: TypesGen.ChatUsageLimitPeriod | undefined,
|
||||
): string => {
|
||||
if (!period) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { chatUsageLimitStatusKey } from "api/queries/chats";
|
||||
import type { FC } from "react";
|
||||
import { useQueryClient } from "react-query";
|
||||
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 periodStart = new Date().toISOString();
|
||||
const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const meta: Meta<typeof UsageIndicator> = {
|
||||
title: "pages/AgentsPage/UsageIndicator",
|
||||
component: UsageIndicator,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export const NotLimited: Story = {
|
||||
decorators: [
|
||||
withUsageLimitStatus({
|
||||
is_limited: false,
|
||||
current_spend: 0,
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { chatUsageLimitStatus } from "api/queries/chats";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import dayjs from "dayjs";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link } from "react-router";
|
||||
import { formatCostMicros } from "utils/currency";
|
||||
import { getUsageLimitPeriodLabel } from "./ChatCostSummaryView";
|
||||
|
||||
export const UsageIndicator: FC = () => {
|
||||
const { data, isLoading, isError } = useQuery(chatUsageLimitStatus());
|
||||
|
||||
if (isLoading || isError || !data?.is_limited) {
|
||||
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 (
|
||||
<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]"
|
||||
>
|
||||
<span className="shrink-0 whitespace-nowrap">
|
||||
{periodLabel} Usage
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/agents/analytics">View details</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user