refactor(dashboard): Provider Quota grouped layout with vertical rail (#2528)

Integrated into release/v3.8.2 (#2528 — thanks @Gi99lin)
This commit is contained in:
Gi99lin
2026-05-22 05:04:59 +03:00
committed by GitHub
parent aa6278da08
commit 7215731444
6 changed files with 1345 additions and 739 deletions

View File

@@ -0,0 +1,631 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import Badge from "@/shared/components/Badge";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import { calculatePercentage, formatQuotaLabel } from "./utils";
import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback";
import type { ResolvedColumn } from "./providerColumns";
/**
* One row inside a ProviderGroup's content column.
*
* Collapsed view: account identity + tier badge + one cell per resolved
* column + overflow count + cutoff/refresh actions. Provider identity is
* rendered by the parent's left rail, not here.
*
* Expanded panel (`isExpanded`): full quota detail laid out as a 2-column
* grid inside the right content area (no full-page-width stretch). Quotas
* whose remaining=100% and used=0 are hidden behind a "Show N unused"
* toggle so the panel stays compact on providers like Antigravity.
*
* All semantics preserved from earlier iterations:
* - `pct` is *remaining* (high = green, low = red)
* - `unlimited`, `staleAfterReset`, `isCredits` branches intact
* - row click toggles expansion; nested controls stop propagation
*/
interface AccountRowProps {
connection: any;
quota: { quotas?: any[]; plan?: string | null; message?: string | null; stale?: any } | undefined;
loading: boolean;
error: string | null;
refreshedAt: string | undefined;
tierMeta: { key: string; label: string; variant: any };
resolvedPlan: string | null;
status: "all" | "critical" | "alert" | "ok" | "empty";
statusTone: { bar: string; text: string; bg: string; ring: string; dot: string };
columns: ResolvedColumn[];
/** Quotas not surfaced as columns; rendered as "+N" overflow chip. */
overflowCount: number;
isExpanded: boolean;
emailsVisible: boolean;
/** Grid template the parent ProviderGroup uses for the column-header row.
* Passed in so account cells align with column headers pixel-perfectly. */
gridTemplateColumns: string;
onToggle: () => void;
onRefresh: () => void;
onOpenCutoff: () => void;
isLast: boolean;
}
const CURRENCY_SYMBOLS: Record<string, string> = {
USD: "$",
CNY: "¥",
EUR: "€",
GBP: "£",
JPY: "¥",
KRW: "₩",
INR: "₹",
};
const QUOTA_BAR_GREEN_THRESHOLD = 50;
const QUOTA_BAR_YELLOW_THRESHOLD = 20;
function getBarColor(remainingPercentage: number) {
if (remainingPercentage > QUOTA_BAR_GREEN_THRESHOLD) {
return { bar: "#22c55e", text: "#22c55e", bg: "rgba(34,197,94,0.12)" };
}
if (remainingPercentage > QUOTA_BAR_YELLOW_THRESHOLD) {
return { bar: "#eab308", text: "#eab308", bg: "rgba(234,179,8,0.12)" };
}
return { bar: "#ef4444", text: "#ef4444", bg: "rgba(239,68,68,0.12)" };
}
function shortWindowLabel(key: string): string {
const map: Record<string, string> = {
session: "5h",
weekly: "7d",
code_review: "review",
};
return map[key] || (key.length > 8 ? `${key.slice(0, 7)}` : key);
}
function formatCountdown(resetAt: string | null | undefined): string | null {
if (!resetAt) return null;
try {
const diff = (new Date(resetAt) as any) - (new Date() as any);
if (diff <= 0) return null;
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
if (h >= 24) {
const d = Math.floor(h / 24);
return `${d}d ${h % 24}h`;
}
return `${h}h ${m}m`;
} catch {
return null;
}
}
/**
* A quota is "unused" when nothing has been consumed: full remaining and no
* recorded usage. Credits-balance entries are never counted here — they
* always carry meaning (account funded amount).
*/
function isUntouched(q: any): boolean {
if (!q) return false;
if (q.isCredits) return false;
const used = Number(q.used || 0);
const remainingPct =
q.remainingPercentage !== undefined
? Number(q.remainingPercentage)
: calculatePercentage(used, Number(q.total || 0));
return used === 0 && remainingPct >= 100;
}
export default function AccountRow({
connection,
quota,
loading,
error,
refreshedAt,
tierMeta,
resolvedPlan,
status,
statusTone,
columns,
overflowCount,
isExpanded,
emailsVisible,
gridTemplateColumns,
onToggle,
onRefresh,
onOpenCutoff,
isLast,
}: AccountRowProps) {
const t = useTranslations("usage");
const tr = (key: string, fallback: string, values?: UsageTranslationValues) =>
translateUsageOrFallback(t, key, fallback, values);
// Local toggle for the expanded panel — show all quotas including
// untouched ones. Default off keeps the panel compact for Antigravity
// and other model-heavy providers.
const [showUnused, setShowUnused] = useState(false);
const overrides = (connection.quotaWindowThresholds || null) as Record<string, number> | null;
const hasOverrides = overrides && Object.keys(overrides).length > 0;
const connectionWindows = (quota?.quotas || []).filter(
(q: any) => q && typeof q.name === "string" && !q.isCredits
);
const connectionHasWindows = connectionWindows.length > 0;
let cutoffLabel: string = tr("quotaCutoffsButtonDefault", "Default");
if (hasOverrides && overrides) {
const entries = Object.entries(overrides);
const visible = entries
.slice(0, 2)
.map(([k, v]) => `${shortWindowLabel(k)}:${v}%`)
.join(" · ");
cutoffLabel = entries.length > 2 ? `${visible} +${entries.length - 2}` : visible;
}
const accountName = pickDisplayValue(
[connection.name, connection.displayName, connection.email],
emailsVisible,
connection.provider
);
// Connection-level staleness cue (restored from the pre-refactor flat table):
// when the provider returned cached/stale cumulative usage, `quota.stale.since`
// carries the moment that snapshot was taken; otherwise we show the last
// successful refresh time. Amber = stale, muted = fresh.
const staleInfo = (quota?.stale || null) as { since?: string; reason?: string } | null;
const displayRefreshedAt = staleInfo?.since || refreshedAt;
const refreshedLabel = displayRefreshedAt
? new Date(displayRefreshedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
: null;
const allQuotas = quota?.quotas || [];
const { visibleQuotas, untouchedCount } = useMemo(() => {
if (showUnused) return { visibleQuotas: allQuotas, untouchedCount: 0 };
const visible: any[] = [];
let untouched = 0;
for (const q of allQuotas) {
if (isUntouched(q)) untouched += 1;
else visible.push(q);
}
return { visibleQuotas: visible, untouchedCount: untouched };
}, [allQuotas, showUnused]);
// Render one column cell — small number + mini bar 24px. Empty cell is
// an em-dash so the column reads as "no data" rather than "0%".
const renderColumnCell = (col: ResolvedColumn) => {
const q = col.quota;
if (!q) {
return (
<div
key={col.key}
className="text-[12px] text-text-muted text-center tabular-nums"
title={tr("noWindowForAccount", "—")}
>
</div>
);
}
if (q.isCredits) {
const colors = getBarColor(q.remainingPercentage ?? 0);
const sym = CURRENCY_SYMBOLS[q.currency] ?? q.currency ?? "";
return (
<span
key={col.key}
className="text-[11px] font-semibold tabular-nums"
style={{ color: colors.text }}
>
{sym}
{(q.creditCount ?? q.remaining ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
);
}
const pctRaw = q.unlimited
? 100
: (q.remainingPercentage ?? calculatePercentage(q.used, q.total));
const pct = Math.round(pctRaw);
const colors = getBarColor(pct);
const usedNum = Number(q.used || 0);
const totalNum = Number(q.total || 0);
const tooltip = q.unlimited
? `${col.label}${tr("unlimitedLabel", "Unlimited")}`
: `${col.label}${pct}% ${tr("remainingShort", "remaining")} (${usedNum.toLocaleString()} / ${totalNum.toLocaleString()})`;
return (
<div key={col.key} className="flex items-center gap-1.5 min-w-0" title={tooltip}>
<span
className="text-[12px] font-semibold tabular-nums leading-none shrink-0"
style={{ color: colors.text }}
>
{q.unlimited ? "∞" : `${pct}%`}
</span>
{q.staleAfterReset && (
<span
className="material-symbols-outlined text-[12px] text-amber-500 shrink-0"
title={t("staleQuotaTooltip")}
>
autorenew
</span>
)}
{!q.unlimited && (
<div className="h-1 w-6 rounded-full bg-black/[0.06] dark:bg-white/[0.06] overflow-hidden shrink-0">
<div
className="h-full rounded-full transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(pct, 100)}%`,
background: colors.bar,
}}
/>
</div>
)}
</div>
);
};
// Compact single-line detail row used inside the expanded panel's
// 2-column grid. Shows: name pill + used/total + countdown + bar + %.
// No card wrapper — the parent grid gives the visual separation.
const renderQuotaDetail = (q: any, i: number) => {
if (q.isCredits) {
const colors = getBarColor(q.remainingPercentage ?? 0);
const sym = CURRENCY_SYMBOLS[q.currency] ?? q.currency ?? "";
const amount = (q.creditCount ?? q.remaining ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return (
<div
key={i}
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded border border-border/60 bg-bg/30 min-w-0"
>
<div className="flex items-center gap-1.5 min-w-0">
<span
className="material-symbols-outlined text-[14px] shrink-0"
style={{ color: colors.text }}
>
paid
</span>
<span className="text-[11px] font-semibold text-text-main truncate">
{formatQuotaLabel(q.name) || tr("creditsLabel", "Credits")}
</span>
</div>
<span
className="text-[12px] font-bold tabular-nums shrink-0"
style={{ color: colors.text }}
>
{sym}
{amount}
</span>
</div>
);
}
const pctRaw = q.unlimited
? 100
: (q.remainingPercentage ?? calculatePercentage(q.used, q.total));
const pct = Math.round(pctRaw);
const colors = getBarColor(pct);
const cd = formatCountdown(q.resetAt);
const shortName = q.displayName || formatQuotaLabel(q.name);
const staleAfterReset = q.staleAfterReset === true;
const usedNum = Number(q.used || 0);
const totalNum = Number(q.total || 0);
const showUsage = totalNum > 0 && !q.unlimited;
return (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5 rounded border border-border/60 bg-bg/30 min-w-0"
title={q.modelKey || q.name}
>
<span
className="text-[11px] font-semibold py-0.5 px-1.5 rounded truncate shrink-0 max-w-[140px]"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<div className="h-1.5 flex-1 min-w-[40px] rounded-full bg-black/[0.06] dark:bg-white/[0.06] overflow-hidden">
<div
className="h-full rounded-full transition-[width] duration-300 ease-out"
style={{ width: `${Math.min(pct, 100)}%`, background: colors.bar }}
/>
</div>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-text-muted tabular-nums shrink-0">
{showUsage && (
<span title={`${usedNum.toLocaleString()} / ${totalNum.toLocaleString()}`}>
{usedNum.toLocaleString()}/{totalNum.toLocaleString()}
</span>
)}
{q.unlimited && <span>{tr("unlimitedLabel", "∞")}</span>}
{staleAfterReset ? (
<span title={tr("refreshing", "Refreshing")}></span>
) : cd ? (
<span title={`${tr("resetsIn", "reset")} ${cd}`}>{cd}</span>
) : null}
</div>
<span
className="text-[12px] font-bold tabular-nums shrink-0 min-w-[34px] text-right"
style={{ color: colors.text }}
>
{pct}%
</span>
</div>
);
};
return (
<div
style={{
borderBottom: !isLast || isExpanded ? "1px solid var(--color-border)" : "none",
}}
>
{/* Collapsed row */}
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle();
}
}}
className="w-full text-left items-center px-3 py-2 transition-[background] duration-150 hover:bg-black/[0.03] dark:hover:bg-white/[0.02] cursor-pointer"
style={{
display: "grid",
gridTemplateColumns,
gap: "12px",
borderLeft: `3px solid ${
status === "all" || status === "empty" ? "transparent" : statusTone.dot
}`,
}}
aria-expanded={isExpanded}
>
{/* Account identity */}
<div className="flex items-center gap-2 min-w-0">
<span className="material-symbols-outlined text-[16px] text-text-muted shrink-0">
{isExpanded ? "expand_less" : "expand_more"}
</span>
<div className="text-[13px] font-semibold text-text-main truncate min-w-0">
{accountName}
</div>
{staleInfo && (
<span
className="material-symbols-outlined text-[13px] text-amber-500 shrink-0"
title={t("staleQuotaTooltip")}
aria-label={t("staleQuotaTooltip")}
>
schedule
</span>
)}
</div>
{/* Tier badge */}
<div className="flex items-center min-w-0">
<span
title={
resolvedPlan ? t("rawPlanWithValue", { plan: resolvedPlan }) : t("noPlanFromProvider")
}
>
<Badge variant={tierMeta.variant} size="sm" dot className="h-5 leading-none">
{tierMeta.label}
</Badge>
</span>
</div>
{/* Quota column cells */}
{loading ? (
<div
className="flex items-center gap-1.5 text-text-muted text-xs"
style={{ gridColumn: `span ${Math.max(columns.length, 1)}` }}
>
<span className="material-symbols-outlined animate-spin text-[14px]">
progress_activity
</span>
{t("loadingQuotas")}
</div>
) : error ? (
<div
className="flex items-center gap-1.5 text-xs text-red-500"
style={{ gridColumn: `span ${Math.max(columns.length, 1)}` }}
>
<span className="material-symbols-outlined text-[14px]">error</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{error}</span>
</div>
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
<div
className="text-xs text-text-muted italic"
style={{ gridColumn: `span ${Math.max(columns.length, 1)}` }}
>
{quota.message}
</div>
) : columns.length === 0 ? (
<div className="text-xs text-text-muted italic" style={{ gridColumn: `span 1` }}>
{t("noQuotaData")}
</div>
) : (
columns.map(renderColumnCell)
)}
{/* Overflow count */}
<div className="text-[11px] text-text-muted text-center tabular-nums">
{overflowCount > 0 ? `+${overflowCount}` : ""}
</div>
{/* Cutoff cell */}
<div className="flex justify-center items-center min-w-0">
<span
onClick={(e) => {
e.stopPropagation();
if (!connectionHasWindows) return;
onOpenCutoff();
}}
role="button"
tabIndex={connectionHasWindows ? 0 : -1}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
if (!connectionHasWindows) return;
onOpenCutoff();
}
}}
title={
connectionHasWindows
? tr(
"quotaCutoffsButtonHelp",
"Edit minimum remaining quota cutoffs for this account."
)
: tr(
"quotaCutoffsButtonDisabled",
"No quota windows are available for this account yet."
)
}
className={`block w-full truncate text-center px-2 py-1 rounded-md border text-[11px] font-medium tabular-nums transition-colors ${
!connectionHasWindows ? "opacity-40 cursor-not-allowed" : "cursor-pointer"
} ${
hasOverrides
? "border-primary/40 text-primary bg-primary/5"
: "border-border text-text-muted hover:bg-black/[0.04] dark:hover:bg-white/[0.04]"
}`}
>
{cutoffLabel}
</span>
</div>
{/* Refresh cell */}
<div className="flex justify-center items-center">
<span
onClick={(e) => {
e.stopPropagation();
if (loading) return;
onRefresh();
}}
role="button"
tabIndex={loading ? -1 : 0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
if (loading) return;
onRefresh();
}
}}
title={t("refreshQuota")}
className={`p-1 rounded-md flex items-center justify-center transition-opacity duration-150 ${
loading
? "cursor-not-allowed opacity-30"
: "cursor-pointer opacity-60 hover:opacity-100"
}`}
>
<span
className={`material-symbols-outlined text-[16px] text-text-muted ${
loading ? "animate-spin" : ""
}`}
>
refresh
</span>
</span>
</div>
</div>
{/* Expanded panel — inline within the right content area, not the
whole page. 2-column responsive grid + show/hide unused toggle. */}
{isExpanded && (
<div className="px-4 py-3 bg-bg-subtle/30 border-t border-border flex flex-col gap-2.5">
{loading ? (
<div className="text-xs text-text-muted flex items-center gap-1.5">
<span className="material-symbols-outlined animate-spin text-[14px]">
progress_activity
</span>
{t("loadingQuotas")}
</div>
) : error ? (
<div className="text-xs text-red-500 flex items-start gap-1.5">
<span className="material-symbols-outlined text-[14px]">error</span>
<span>{error}</span>
</div>
) : allQuotas.length > 0 ? (
<>
{visibleQuotas.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{visibleQuotas.map(renderQuotaDetail)}
</div>
) : (
<div className="text-[11px] text-text-muted italic">
{tr("allQuotasUnused", "All quotas untouched")}
</div>
)}
<div className="flex items-center justify-between gap-2 pt-1.5 border-t border-border/40">
<div className="flex items-center gap-2 min-w-0">
{untouchedCount > 0 && (
<button
type="button"
onClick={() => setShowUnused((v) => !v)}
className="text-[11px] text-text-muted hover:text-text-main underline-offset-2 hover:underline cursor-pointer"
>
{showUnused
? tr("hideUnusedQuotas", "Hide untouched")
: tr("showUnusedQuotas", "Show {count} untouched", {
count: untouchedCount,
})}
</button>
)}
{refreshedLabel && (
<span
className={`text-[10px] tabular-nums ${
staleInfo ? "text-amber-500" : "text-text-muted"
}`}
title={
staleInfo
? t("staleQuotaTooltip")
: `${tr("lastRefreshed", "Last refreshed")}: ${refreshedLabel}`
}
>
{tr("updatedShort", "Updated")} {refreshedLabel}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={!connectionHasWindows}
onClick={onOpenCutoff}
className="inline-flex items-center gap-1.5 text-[11px] font-medium px-2.5 py-1 rounded-md border border-border bg-bg-subtle hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
<span className="material-symbols-outlined text-[13px]">tune</span>
{tr("editCutoffs", "Editar Cutoffs")}
</button>
<button
type="button"
disabled={loading}
onClick={onRefresh}
className="inline-flex items-center gap-1.5 text-[11px] font-medium px-2.5 py-1 rounded-md border border-border bg-bg-subtle hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
<span
className={`material-symbols-outlined text-[13px] ${
loading ? "animate-spin" : ""
}`}
>
refresh
</span>
{tr("forceRefresh", "Refresh agora")}
</button>
</div>
</div>
</>
) : (
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useTranslations } from "next-intl";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback";
import type { ResolvedColumn } from "./providerColumns";
/**
* Provider group rendered as a 2-column grid:
* - left rail (fixed width) hosts the provider identity, vertically centered:
* icon + display name + account count + worst-status dot + bulk refresh
* - right content column hosts a thin per-group column-header row followed
* by the AccountRow stack passed in via `children`.
*
* The rail eliminates the per-row provider duplication seen in the previous
* flat-table iteration without taking a full row for a header banner.
*
* `gridTemplateColumns` from `buildGridTemplate(columns.length)` is the
* single source of truth shared between the column-header row inside this
* component and each AccountRow nested in `children`.
*/
interface ProviderGroupProps {
providerKey: string;
providerLabel: string;
accountCount: number;
/** Worst status across the group — drives the rail dot color. */
worstStatus: "critical" | "alert" | "ok" | "empty";
columns: ResolvedColumn[];
overflowMax: number;
isRefreshing: boolean;
onRefreshGroup: () => void;
children: React.ReactNode;
}
const STATUS_DOT: Record<"critical" | "alert" | "ok" | "empty", string> = {
critical: "#ef4444",
alert: "#eab308",
ok: "#22c55e",
empty: "var(--color-text-muted)",
};
/**
* Grid layout shared between the group's column-header row and each
* AccountRow's collapsed body. Columns:
* identity | tier | quota-columns... | overflow | cutoff | refresh
*
* Provider lives in the rail (outside this grid), so it has no column here.
*/
export function buildGridTemplate(columnCount: number): string {
const identityWidth = columnCount <= 1 ? "minmax(220px, 2.4fr)" : "minmax(180px, 2fr)";
const tierWidth = "minmax(64px, 80px)";
const columnsTpl =
columnCount > 0 ? Array(columnCount).fill("minmax(76px, 1fr)").join(" ") : "minmax(120px, 1fr)";
const overflowWidth = "36px";
const cutoffWidth = "minmax(76px, 96px)";
const refreshWidth = "32px";
return [identityWidth, tierWidth, columnsTpl, overflowWidth, cutoffWidth, refreshWidth].join(" ");
}
export default function ProviderGroup({
providerKey,
providerLabel,
accountCount,
worstStatus,
columns,
overflowMax,
isRefreshing,
onRefreshGroup,
children,
}: ProviderGroupProps) {
const t = useTranslations("usage");
const tr = (key: string, fallback: string, values?: UsageTranslationValues) =>
translateUsageOrFallback(t, key, fallback, values);
const grid = buildGridTemplate(columns.length);
return (
<div
className="grid border border-border rounded-lg overflow-hidden bg-surface"
style={{ gridTemplateColumns: "140px 1fr" }}
>
{/* Rail */}
<div className="flex flex-col items-center justify-center gap-1.5 px-2 py-3 bg-bg-subtle/40 border-r border-border min-h-full">
<div className="w-8 h-8 rounded-md flex items-center justify-center overflow-hidden shrink-0">
<ProviderIcon
providerId={providerKey}
size={32}
type="color"
className="object-contain"
/>
</div>
<span
className="text-[12px] font-semibold text-text-main text-center leading-tight truncate max-w-full"
title={providerLabel}
>
{providerLabel}
</span>
<div className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: STATUS_DOT[worstStatus] }}
aria-hidden
title={tr(`statusDot_${worstStatus}`, worstStatus)}
/>
<span className="text-[10px] text-text-muted tabular-nums">
{tr("groupAccountsCount", "{count} accounts", { count: accountCount })}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (isRefreshing) return;
onRefreshGroup();
}}
disabled={isRefreshing}
title={tr("refreshGroup", "Refresh all accounts in this group")}
className="mt-0.5 p-1 rounded-md text-text-muted hover:text-text-main hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
<span
className={`material-symbols-outlined text-[14px] ${isRefreshing ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
</div>
{/* Content */}
<div className="flex flex-col min-w-0">
{/* Column header row — thin, muted */}
<div
className="px-3 py-1 bg-bg-subtle/20 text-[9px] uppercase tracking-wider text-text-muted font-semibold border-b border-border/40"
style={{
display: "grid",
gridTemplateColumns: grid,
gap: "12px",
}}
>
<div>{tr("columnAccount", "Account")}</div>
<div>{tr("columnTier", "Tier")}</div>
{columns.length > 0 ? (
columns.map((c) => (
<div key={c.key} className="truncate" title={c.label}>
{c.label}
</div>
))
) : (
<div>{tr("columnQuota", "Quota")}</div>
)}
<div className="text-center" title={tr("overflowHint", "Additional quotas")}>
{overflowMax > 0 ? "+" : ""}
</div>
<div className="text-center">{tr("columnCutoff", "Cutoff")}</div>
<div className="text-center"></div>
</div>
{/* Account rows */}
<div>{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { formatQuotaLabel } from "./utils";
/**
* Per-provider column schema for the grouped Provider Quota layout.
*
* Each entry lists the canonical quota keys we want to surface as
* fixed-width table columns. Matching is done both exact (by `quota.name`)
* and via the normalized label (so MiniMax's `"session (5h)"` still lands in
* the `"session"` column).
*
* Providers not listed here fall back to a dynamic schema: take the first
* `MAX_DYNAMIC_COLUMNS` quotas in the order returned by `parseQuotaData()`
* and surface them as columns; everything else becomes "+N more".
*/
const PROVIDER_COLUMNS: Record<string, string[]> = {
codex: ["session", "weekly"],
claude: ["session", "weekly"],
glm: ["session", "weekly", "mcp_monthly"],
"glm-cn": ["session", "weekly", "mcp_monthly"],
glmt: ["session", "weekly", "mcp_monthly"],
zai: ["session", "weekly", "mcp_monthly"],
github: ["chat", "completions", "premium_interactions"],
minimax: ["session"],
"minimax-cn": ["session"],
"kimi-coding": ["session", "weekly"],
};
/** Hard cap for the dynamic schema (Antigravity, Gemini-CLI, fallback). */
export const MAX_DYNAMIC_COLUMNS = 3;
export interface ResolvedColumn {
/** Stable column key — used for React keys and column-picker state. */
key: string;
/** Human-readable header label. */
label: string;
/** The matching quota for this account, or `null` if the account
* doesn't expose that window. Rendered as an em-dash cell in the UI. */
quota: any | null;
}
export interface ResolvedSchema {
columns: ResolvedColumn[];
/** Quotas present on the account but not surfaced as columns.
* Rendered as "+N more" in the row's trailing cell. */
overflowCount: number;
}
function matchQuotaByKey(quotas: any[], key: string): any | null {
// Exact match on quota.name first
const exact = quotas.find(
(q) => q && typeof q.name === "string" && q.name.toLowerCase() === key.toLowerCase()
);
if (exact) return exact;
// Then exact match on modelKey (for antigravity etc.)
const byModel = quotas.find(
(q) => q && typeof q.modelKey === "string" && q.modelKey.toLowerCase() === key.toLowerCase()
);
if (byModel) return byModel;
// Finally, normalized label match — handles "session (5h)" → "session"
const normalized = formatQuotaLabel(key).toLowerCase();
return (
quotas.find(
(q) =>
q && typeof q.name === "string" && formatQuotaLabel(q.name).toLowerCase() === normalized
) || null
);
}
/**
* Resolve which columns to render for a given (provider, quotas) pair.
*
* - Named providers: use the static schema; missing windows render as `null`.
* - Unknown providers: take the first N non-credit quotas in array order.
* - Credits (`isCredits === true`) are never used as columns — they render
* in the overflow tooltip / expanded panel as a balance, not as a %.
*/
export function getProviderColumns(provider: string, quotas: any[] = []): ResolvedSchema {
const safe = Array.isArray(quotas) ? quotas : [];
const nonCredits = safe.filter((q) => q && !q.isCredits);
const credits = safe.filter((q) => q && q.isCredits);
const named = PROVIDER_COLUMNS[String(provider || "").toLowerCase()];
if (named && named.length > 0) {
const columns: ResolvedColumn[] = named.map((key) => ({
key,
label: formatQuotaLabel(key),
quota: matchQuotaByKey(nonCredits, key),
}));
const matchedQuotas = new Set(columns.map((c) => c.quota).filter(Boolean));
const overflowCount = nonCredits.filter((q) => !matchedQuotas.has(q)).length + credits.length;
return { columns, overflowCount };
}
// Dynamic schema — take the first N non-credit quotas in array order
const visible = nonCredits.slice(0, MAX_DYNAMIC_COLUMNS);
const columns: ResolvedColumn[] = visible.map((q) => ({
key: q.modelKey || q.name,
label: q.displayName || formatQuotaLabel(q.name),
quota: q,
}));
const overflowCount = Math.max(0, nonCredits.length - visible.length) + credits.length;
return { columns, overflowCount };
}
/**
* Group a flat list of connection objects by their `provider` key.
* Preserves the input order (the upstream sort by status + soonest reset is
* meaningful inside each group; the provider order itself is controlled by
* the caller via `PROVIDER_ORDER` in index.tsx).
*/
export function groupConnectionsByProvider<T extends { provider: string }>(
connections: T[]
): Map<string, T[]> {
const groups = new Map<string, T[]>();
for (const conn of connections) {
const key = conn.provider || "unknown";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(conn);
}
return groups;
}

View File

@@ -5309,7 +5309,9 @@
"budgetWarnAtPct": "Warn at %",
"quotaAlerts": "Quota alerts",
"quotaTableRefreshing": "⟳ Refreshing...",
"noSpendLast30Days": "No spend in last 30 days"
"noSpendLast30Days": "No spend in last 30 days",
"updatedShort": "Updated",
"lastRefreshed": "Last refreshed"
},
"modals": {
"waitingAuth": "Waiting for Authorization",

View File

@@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
const providerColumns =
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/providerColumns.ts");
const utils =
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx");
test("getProviderColumns: Codex surfaces session + weekly columns", () => {
const quotas = utils.parseQuotaData("codex", {
quotas: {
session: { used: 4, total: 100, remainingPercentage: 96 },
weekly: { used: 1, total: 100, remainingPercentage: 99 },
},
});
const schema = providerColumns.getProviderColumns("codex", quotas);
assert.equal(schema.columns.length, 2);
assert.equal(schema.columns[0].key, "session");
assert.equal(schema.columns[0].label, "Session");
assert.equal(schema.columns[0].quota?.name, "session");
assert.equal(schema.columns[1].key, "weekly");
assert.equal(schema.columns[1].quota?.name, "weekly");
assert.equal(schema.overflowCount, 0);
});
test("getProviderColumns: missing window for a named column renders as null cell", () => {
// Codex account that has only a session window (no weekly yet)
const quotas = utils.parseQuotaData("codex", {
quotas: {
session: { used: 4, total: 100, remainingPercentage: 96 },
},
});
const schema = providerColumns.getProviderColumns("codex", quotas);
assert.equal(schema.columns.length, 2, "schema column count stays stable per provider");
assert.equal(schema.columns[0].quota?.name, "session");
assert.equal(schema.columns[1].quota, null, "missing weekly resolves to null, not overflow");
assert.equal(schema.overflowCount, 0);
});
test("getProviderColumns: MiniMax `session (5h)` matches the `session` column via normalized label", () => {
const quotas = utils.parseQuotaData("minimax", {
quotas: {
"session (5h)": {
used: 100,
total: 100,
remainingPercentage: 0,
},
},
});
const schema = providerColumns.getProviderColumns("minimax", quotas);
assert.equal(schema.columns.length, 1);
assert.equal(schema.columns[0].key, "session");
assert.equal(schema.columns[0].label, "Session");
assert.equal(
schema.columns[0].quota?.name,
"session (5h)",
"the original quota object is attached, not a normalized clone"
);
assert.equal(schema.overflowCount, 0);
});
test("getProviderColumns: Antigravity falls back to dynamic schema (first 3 quotas)", () => {
const quotas = utils.parseQuotaData("antigravity", {
quotas: {
"claude-opus-4-6-thinking": { used: 0, total: 100, remainingPercentage: 100 },
"claude-sonnet-4-6": { used: 0, total: 100, remainingPercentage: 100 },
"gemini-3.1-pro-low": { used: 0, total: 100, remainingPercentage: 100 },
"gemini-3-flash-agent": { used: 0, total: 100, remainingPercentage: 100 },
"gemini-3.5-flash-low": { used: 0, total: 100, remainingPercentage: 100 },
},
});
const schema = providerColumns.getProviderColumns("antigravity", quotas);
assert.equal(schema.columns.length, providerColumns.MAX_DYNAMIC_COLUMNS);
assert.equal(schema.overflowCount, 2, "5 quotas - 3 visible = 2 overflow");
});
test("getProviderColumns: credits never become columns, always counted toward overflow", () => {
// DeepSeek surfaces a single credits-balance row
const quotas = utils.parseQuotaData("deepseek", {
quotas: {
credits_usd: { remaining: 47.5, currency: "USD" },
},
});
const schema = providerColumns.getProviderColumns("deepseek", quotas);
assert.equal(schema.columns.length, 0, "no non-credit quotas means no columns");
assert.equal(schema.overflowCount, 1, "the credits row is surfaced as overflow");
});
test("getProviderColumns: unknown provider uses dynamic fallback", () => {
const quotas = [
{ name: "foo", used: 10, total: 100 },
{ name: "bar", used: 20, total: 100 },
];
const schema = providerColumns.getProviderColumns("some-future-provider", quotas);
assert.equal(schema.columns.length, 2);
assert.equal(schema.columns[0].key, "foo");
assert.equal(schema.columns[1].key, "bar");
assert.equal(schema.overflowCount, 0);
});
test("getProviderColumns: tolerates non-array quotas", () => {
// @ts-expect-error — exercise the runtime guard
const schema = providerColumns.getProviderColumns("codex", null);
assert.equal(schema.columns.length, 2);
assert.equal(schema.columns[0].quota, null);
assert.equal(schema.overflowCount, 0);
});
test("groupConnectionsByProvider: preserves input order inside each group", () => {
const conns = [
{ id: "a", provider: "codex" },
{ id: "b", provider: "antigravity" },
{ id: "c", provider: "codex" },
{ id: "d", provider: "antigravity" },
{ id: "e", provider: "codex" },
];
const groups = providerColumns.groupConnectionsByProvider(conns);
assert.deepEqual(
[...groups.keys()],
["codex", "antigravity"],
"group order reflects first appearance"
);
assert.deepEqual(
groups.get("codex")!.map((c) => c.id),
["a", "c", "e"]
);
assert.deepEqual(
groups.get("antigravity")!.map((c) => c.id),
["b", "d"]
);
});
test("groupConnectionsByProvider: missing provider key collapses into 'unknown'", () => {
const conns = [
{ id: "x", provider: "" } as { id: string; provider: string },
{ id: "y" } as unknown as { id: string; provider: string },
];
const groups = providerColumns.groupConnectionsByProvider(conns);
assert.equal(groups.size, 1);
assert.deepEqual(
groups.get("unknown")!.map((c) => c.id),
["x", "y"]
);
});