diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index b5da6bf..fa2193c 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -47,7 +47,9 @@ import { type ProviderNativeUsageWindow, RuntimeUsageSection, aggregateRuntimeUsage, + buildPerGatewayUsage, type AggregatedRuntimeUsage, + type PerGatewayUsage, } from "@/components/dashboard/RuntimeUsageSection"; import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel"; import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel"; @@ -647,8 +649,11 @@ export default function DashboardPage() { () => gatewayStatusesQuery.data ?? [], [gatewayStatusesQuery.data], ); - // Runtime usage — query all gateways in parallel, aggregate - const runtimeUsageQuery = useQuery({ + // Runtime usage — query all gateways in parallel, aggregate + per-gateway + const runtimeUsageQuery = useQuery< + { aggregate: AggregatedRuntimeUsage; perGateway: PerGatewayUsage[] }, + ApiError + >({ queryKey: [ "dashboard", "runtime-usage", @@ -674,11 +679,19 @@ export default function DashboardPage() { r.status === "fulfilled" && r.value !== null, ) .map((r) => r.value); - return aggregateRuntimeUsage(valid); + const labels: Record = {}; + for (const t of gatewayTargets) { + labels[t.gatewayId] = t.boardName; + } + return { + aggregate: aggregateRuntimeUsage(valid), + perGateway: buildPerGatewayUsage(valid, labels), + }; }, }); - const runtimeUsage = runtimeUsageQuery.data ?? null; + const runtimeUsage = runtimeUsageQuery.data?.aggregate ?? null; + const perGatewayUsage = runtimeUsageQuery.data?.perGateway ?? []; const providerUsageQuery = useQuery({ queryKey: [ "dashboard", @@ -1186,6 +1199,7 @@ export default function DashboardPage() { diff --git a/frontend/src/components/dashboard/RuntimeUsageSection.tsx b/frontend/src/components/dashboard/RuntimeUsageSection.tsx index 0b29294..88b4265 100644 --- a/frontend/src/components/dashboard/RuntimeUsageSection.tsx +++ b/frontend/src/components/dashboard/RuntimeUsageSection.tsx @@ -39,6 +39,97 @@ export interface AggregatedRuntimeUsage { topSessions: TopSession[]; } +// One gateway's most-constrained limit — used for per-account rows. +export interface PerGatewayUsage { + gatewayId: string; + gatewayLabel: string; // human name shown in the row + accountKey: string; + limitKind: string; // "output_tokens" | "total_tokens" | "messages" | "cost" | "none" + limitLabel: string; // e.g. "output tokens", "messages" + limitValue: number | null; + usedValue: number; + pctUsed: number | null; // 0–100, derived from the most constraining limit + timeToLimitMs: number | null; + safe: boolean; + resetInMs: number; + totalCostUsd: number; +} + +// Build per-gateway constraint rows from raw API responses. +export function buildPerGatewayUsage( + responses: RuntimeUsageResponse[], + labels: Record, // gatewayId → display label +): PerGatewayUsage[] { + return responses + .map((r): PerGatewayUsage => { + const gid = String(r.gateway_id); + const label = labels[gid] ?? gid.slice(0, 8); + const c = r.current; + + // Pick the most constrained typed limit (highest pct used) + type Candidate = { kind: string; label: string; limit: number; used: number; pct: number }; + const candidates: Candidate[] = []; + + if (c.output_token_limit && c.output_token_limit_pct !== null) { + candidates.push({ + kind: "output_tokens", label: "output tokens", + limit: c.output_token_limit, + used: c.total_output_tokens ?? 0, + pct: c.output_token_limit_pct ?? 0, + }); + } + if (c.total_token_limit && c.total_token_limit_pct !== null) { + candidates.push({ + kind: "total_tokens", label: "total tokens", + limit: c.total_token_limit, + used: c.total_tokens, + pct: c.total_token_limit_pct ?? 0, + }); + } + // legacy token_limit only when no typed token limit present + if (!c.output_token_limit && !c.total_token_limit && c.token_limit && c.token_pct !== null) { + candidates.push({ + kind: "total_tokens", label: "tokens", + limit: c.token_limit, + used: c.total_tokens, + pct: c.token_pct ?? 0, + }); + } + if (c.message_limit && c.message_pct !== null) { + candidates.push({ + kind: "messages", label: "messages", + limit: c.message_limit, + used: c.total_calls, + pct: c.message_pct ?? 0, + }); + } + + // Most constrained = highest pct used + const binding = candidates.length > 0 + ? candidates.reduce((a, b) => a.pct >= b.pct ? a : b) + : null; + + return { + gatewayId: gid, + gatewayLabel: label, + accountKey: gid, + limitKind: binding?.kind ?? "none", + limitLabel: binding?.label ?? "no limit", + limitValue: binding?.limit ?? null, + usedValue: binding?.used ?? 0, + pctUsed: binding?.pct ?? null, + timeToLimitMs: r.predictions.time_to_limit_ms ?? null, + safe: r.predictions.safe, + resetInMs: r.window.reset_in_ms, + totalCostUsd: c.total_cost_usd, + }; + }) + // Only surface rows that have a configured limit — otherwise it is noise + .filter((row) => row.limitKind !== "none") + // Most exhausted first + .sort((a, b) => (b.pctUsed ?? 0) - (a.pctUsed ?? 0)); +} + export interface ProviderNativeUsageWindow { key: string; label: string; @@ -278,6 +369,7 @@ function StatCard({ label, value, sub, tone = "default", icon }: StatCardProps) interface RuntimeUsageSectionProps { usage: AggregatedRuntimeUsage | null; providerUsageWindows: ProviderNativeUsageWindow[]; + perGatewayUsage: PerGatewayUsage[]; isLoading: boolean; hasGateways: boolean; } @@ -285,6 +377,7 @@ interface RuntimeUsageSectionProps { export function RuntimeUsageSection({ usage, providerUsageWindows, + perGatewayUsage, isLoading, hasGateways, }: RuntimeUsageSectionProps) { @@ -313,7 +406,8 @@ export function RuntimeUsageSection({ const right = order[b.key] ?? 99; return left === right ? a.gatewayLabel.localeCompare(b.gatewayLabel) : left - right; }); - const noData = !hasRuntimeData && providerRows.length === 0; + const noData = !hasRuntimeData && providerRows.length === 0 && perGatewayUsage.length === 0; + const showCombinedLabel = perGatewayUsage.length > 1; return ( @@ -321,7 +415,7 @@ export function RuntimeUsageSection({ ) : noData ? ( - ) : hasRuntimeData || providerRows.length > 0 ? ( + ) : hasRuntimeData || providerRows.length > 0 || perGatewayUsage.length > 0 ? (
{providerRows.length > 0 && (
@@ -371,23 +465,76 @@ export function RuntimeUsageSection({
)} + {/* Per-account constraint rows — most exhausted first */} + {perGatewayUsage.length > 0 && ( +
+

+ Per-account limits +

+
+ {perGatewayUsage.map((row) => { + const pct = row.pctUsed ?? 0; + const barTone = + pct >= 90 + ? "bg-[color:var(--danger)]" + : pct >= 75 + ? "bg-[color:var(--warning)]" + : "bg-[color:var(--success)]"; + const dangerLabel = + pct >= 90 ? "text-[color:var(--danger)]" + : pct >= 75 ? "text-[color:var(--warning)]" + : "text-muted"; + return ( +
+
+

{row.gatewayLabel}

+
+ {row.timeToLimitMs !== null && ( + + {row.timeToLimitMs === 0 ? "at limit" : `${fmtMs(row.timeToLimitMs)} left`} + + )} + + {row.pctUsed === null ? "—" : `${Math.round(pct)}%`} + +
+
+
+
+
+

+ {row.limitLabel} · resets in {fmtMs(row.resetInMs)} · {fmtCost(row.totalCostUsd)} spent +

+
+ ); + })} +
+
+ )} + {usage && hasRuntimeData && (
0.8 ? "warning" : "default"} icon={} /> } /> { if (usage.outputTokenLimit) {