diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/AccountRow.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/AccountRow.tsx new file mode 100644 index 0000000000..7c2dc4dc0f --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/AccountRow.tsx @@ -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 = { + 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 = { + 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 | 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 ( +
+ — +
+ ); + } + if (q.isCredits) { + const colors = getBarColor(q.remainingPercentage ?? 0); + const sym = CURRENCY_SYMBOLS[q.currency] ?? q.currency ?? ""; + return ( + + {sym} + {(q.creditCount ?? q.remaining ?? 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + ); + } + 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 ( +
+ + {q.unlimited ? "∞" : `${pct}%`} + + {q.staleAfterReset && ( + + autorenew + + )} + {!q.unlimited && ( +
+
+
+ )} +
+ ); + }; + + // 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 ( +
+
+ + paid + + + {formatQuotaLabel(q.name) || tr("creditsLabel", "Credits")} + +
+ + {sym} + {amount} + +
+ ); + } + + 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 ( +
+ + {shortName} + +
+
+
+
+
+
+ {showUsage && ( + + {usedNum.toLocaleString()}/{totalNum.toLocaleString()} + + )} + {q.unlimited && {tr("unlimitedLabel", "∞")}} + {staleAfterReset ? ( + + ) : cd ? ( + ⏱{cd} + ) : null} +
+ + {pct}% + +
+ ); + }; + + return ( +
+ {/* Collapsed row */} +
{ + 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 */} +
+ + {isExpanded ? "expand_less" : "expand_more"} + +
+ {accountName} +
+ {staleInfo && ( + + schedule + + )} +
+ + {/* Tier badge */} +
+ + + {tierMeta.label} + + +
+ + {/* Quota column cells */} + {loading ? ( +
+ + progress_activity + + {t("loadingQuotas")} +
+ ) : error ? ( +
+ error + {error} +
+ ) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? ( +
+ {quota.message} +
+ ) : columns.length === 0 ? ( +
+ {t("noQuotaData")} +
+ ) : ( + columns.map(renderColumnCell) + )} + + {/* Overflow count */} +
+ {overflowCount > 0 ? `+${overflowCount}` : ""} +
+ + {/* Cutoff cell */} +
+ { + 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} + +
+ + {/* Refresh cell */} +
+ { + 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" + }`} + > + + refresh + + +
+
+ + {/* Expanded panel — inline within the right content area, not the + whole page. 2-column responsive grid + show/hide unused toggle. */} + {isExpanded && ( +
+ {loading ? ( +
+ + progress_activity + + {t("loadingQuotas")} +
+ ) : error ? ( +
+ error + {error} +
+ ) : allQuotas.length > 0 ? ( + <> + {visibleQuotas.length > 0 ? ( +
+ {visibleQuotas.map(renderQuotaDetail)} +
+ ) : ( +
+ {tr("allQuotasUnused", "All quotas untouched")} +
+ )} +
+
+ {untouchedCount > 0 && ( + + )} + {refreshedLabel && ( + + {tr("updatedShort", "Updated")} {refreshedLabel} + + )} +
+
+ + +
+
+ + ) : ( +
{t("noQuotaData")}
+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderGroup.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderGroup.tsx new file mode 100644 index 0000000000..4c910b89be --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderGroup.tsx @@ -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 ( +
+ {/* Rail */} +
+
+ +
+ + {providerLabel} + +
+ + + {tr("groupAccountsCount", "{count} accounts", { count: accountCount })} + +
+ +
+ + {/* Content */} +
+ {/* Column header row — thin, muted */} +
+
{tr("columnAccount", "Account")}
+
{tr("columnTier", "Tier")}
+ {columns.length > 0 ? ( + columns.map((c) => ( +
+ {c.label} +
+ )) + ) : ( +
{tr("columnQuota", "Quota")}
+ )} +
+ {overflowMax > 0 ? "+" : ""} +
+
{tr("columnCutoff", "Cutoff")}
+
+
+ + {/* Account rows */} +
{children}
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 27642823f1..c772cb22a4 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -5,61 +5,67 @@ import { useTranslations } from "next-intl"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { parseQuotaData, - calculatePercentage, formatQuotaLabel, normalizePlanTier, resolvePlanValue, + calculatePercentage, } from "./utils"; import Card from "@/shared/components/Card"; -import Badge from "@/shared/components/Badge"; import { CardSkeleton } from "@/shared/components/Loading"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; -import { pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; -import ProviderIcon from "@/shared/components/ProviderIcon"; import QuotaCutoffModal from "./QuotaCutoffModal"; +import ProviderGroup, { buildGridTemplate } from "./ProviderGroup"; +import AccountRow from "./AccountRow"; +import { getProviderColumns, groupConnectionsByProvider } from "./providerColumns"; import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; -const LS_GROUP_BY = "omniroute:limits:groupBy"; -const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups"; const LS_EXPANDED_ROWS = "omniroute:limits:expandedRows"; const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter"; const LS_STATUS_FILTER = "omniroute:limits:statusFilter"; +const LS_ENV_FILTER = "omniroute:limits:envFilter"; -const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches +const MIN_FETCH_INTERVAL_MS = 30000; const QUOTA_BAR_GREEN_THRESHOLD = 50; const QUOTA_BAR_YELLOW_THRESHOLD = 20; -const LIMITS_GRID_TEMPLATE_COLUMNS = "minmax(220px,260px) minmax(240px,1fr) 104px 76px 56px"; -// Provider display config -const PROVIDER_CONFIG = { - antigravity: { label: "Antigravity", color: "#F59E0B" }, - "gemini-cli": { label: "Gemini CLI", color: "#4285F4" }, - github: { label: "GitHub Copilot", color: "#333" }, - kiro: { label: "Kiro AI", color: "#FF6B35" }, - "amazon-q": { label: "Amazon Q", color: "#FF9900" }, - codex: { label: "OpenAI Codex", color: "#10A37F" }, - claude: { label: "Claude Code", color: "#D97757" }, - glm: { label: "GLM (Z.AI)", color: "#4A90D9" }, - zai: { label: "Z.AI", color: "#2563EB" }, - glmt: { label: "GLM Thinking", color: "#2563EB" }, - "kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" }, - minimax: { label: "MiniMax", color: "#7C3AED" }, - "minimax-cn": { label: "MiniMax CN", color: "#DC2626" }, - nanogpt: { label: "NanoGPT", color: "#4F46E5" }, - deepseek: { label: "DeepSeek", color: "#4D6BFE" }, +// Display label per known provider; the icon is resolved by ProviderIcon. +const PROVIDER_LABEL: Record = { + antigravity: "Antigravity", + "gemini-cli": "Gemini CLI", + github: "GitHub Copilot", + kiro: "Kiro AI", + "amazon-q": "Amazon Q", + codex: "OpenAI Codex", + claude: "Claude Code", + glm: "GLM (Z.AI)", + zai: "Z.AI", + glmt: "GLM Thinking", + "kimi-coding": "Kimi Coding", + minimax: "MiniMax", + "minimax-cn": "MiniMax CN", + nanogpt: "NanoGPT", + deepseek: "DeepSeek", }; -// Currency symbol mapping -const CURRENCY_SYMBOLS: Record = { - USD: "$", - CNY: "¥", - EUR: "€", - GBP: "£", - JPY: "¥", - KRW: "₩", - INR: "₹", +// Group ordering — single source of truth for "where does Codex sit +// relative to Antigravity on the page". +const PROVIDER_ORDER: Record = { + antigravity: 1, + "gemini-cli": 2, + github: 3, + codex: 4, + claude: 5, + kiro: 6, + glm: 7, + zai: 8, + glmt: 9, + "kimi-coding": 10, + minimax: 11, + "minimax-cn": 12, + nanogpt: 13, }; const TIER_FILTERS = [ @@ -85,8 +91,6 @@ const PURCHASE_TYPES: Array<{ key: PurchaseTypeKey; labelKey: string; fallback: { key: "apikey", labelKey: "purchaseApiKey", fallback: "API Key" }, ]; -// Classify a connection into a purchase-type bucket. Free/unknown tiers on -// OAuth are treated as "oauth-free"; all other OAuth as "oauth-sub". function getPurchaseType(authType: string | undefined, tierKey: string): PurchaseTypeKey { if (authType === "apikey") return "apikey"; if (authType === "oauth") { @@ -96,9 +100,6 @@ function getPurchaseType(authType: string | undefined, tierKey: string): Purchas return "oauth-free"; } -// Worst-case status across a connection's quotas. "empty" only when there are -// no quota windows at all (covers credit-only providers via the isCredits -// branch separately). function getWorstStatus(quotas: any[] | undefined): StatusKey { if (!quotas || quotas.length === 0) return "empty"; let worst: "ok" | "alert" = "ok"; @@ -110,8 +111,6 @@ function getWorstStatus(quotas: any[] | undefined): StatusKey { return worst; } -// Soonest upcoming reset timestamp across a connection's quotas. Used to -// sort "expiring first". Returns Infinity when nothing is scheduled. function getSoonestResetMs(quotas: any[] | undefined): number { if (!quotas || quotas.length === 0) return Number.POSITIVE_INFINITY; const now = Date.now(); @@ -165,45 +164,15 @@ const STATUS_TONE: Record< }, }; -// Get bar color based on remaining percentage -function getBarColor(remainingPercentage) { - 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)" }; -} - -// Short label for a quota-window key, used in the inline cutoff summary -// ("session:90% · weekly:80%"). Unknown keys fall back to the key itself, -// shortened to keep the button compact. -function shortWindowLabel(key: string): string { - const map: Record = { - session: "5h", - weekly: "7d", - code_review: "review", - }; - return map[key] || (key.length > 8 ? `${key.slice(0, 7)}…` : key); -} - -// Format countdown -function formatCountdown(resetAt) { - 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; +// Worst aggregate across a list of statuses — drives the group header dot. +function aggregateWorst(statuses: StatusKey[]): "critical" | "alert" | "ok" | "empty" { + let worst: "ok" | "alert" | "empty" = "empty"; + for (const s of statuses) { + if (s === "critical") return "critical"; + if (s === "alert" && worst !== "alert") worst = "alert"; + if (s === "ok" && worst === "empty") worst = "ok"; } + return worst; } export default function ProviderLimits() { @@ -214,29 +183,15 @@ export default function ProviderLimits() { [t] ); const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); - const [connections, setConnections] = useState([]); - const [quotaData, setQuotaData] = useState({}); - const [loading, setLoading] = useState({}); - const [errors, setErrors] = useState({}); + const [connections, setConnections] = useState([]); + const [quotaData, setQuotaData] = useState>({}); + const [loading, setLoading] = useState>({}); + const [errors, setErrors] = useState>({}); const [lastRefreshedAt, setLastRefreshedAt] = useState>({}); const [refreshingAll, setRefreshingAll] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [tierFilter, setTierFilter] = useState("all"); - const [groupBy, setGroupBy] = useState<"none" | "environment">(() => { - if (typeof window === "undefined") return "none"; - const saved = localStorage.getItem(LS_GROUP_BY); - if (saved === "environment" || saved === "none") return saved; - return "none"; - }); - const [expandedGroups, setExpandedGroups] = useState>(() => { - if (typeof window === "undefined") return new Set(); - try { - const saved = localStorage.getItem(LS_EXPANDED_GROUPS); - return saved ? new Set(JSON.parse(saved)) : new Set(); - } catch { - return new Set(); - } - }); + const [expandedRows, setExpandedRows] = useState>(() => { if (typeof window === "undefined") return new Set(); try { @@ -246,6 +201,7 @@ export default function ProviderLimits() { return new Set(); } }); + const [purchaseTypeFilter, setPurchaseTypeFilter] = useState(() => { if (typeof window === "undefined") return "all"; const saved = localStorage.getItem(LS_PURCHASE_FILTER) as PurchaseTypeKey | null; @@ -258,14 +214,16 @@ export default function ProviderLimits() { return saved; return "all"; }); + const [envFilter, setEnvFilter] = useState(() => { + if (typeof window === "undefined") return "all"; + return localStorage.getItem(LS_ENV_FILTER) || "all"; + }); - const lastFetchTimeRef = useRef({}); - const staleProbeRef = useRef({}); - // Cutoff modal state: connection being edited, the window list captured at - // open time (from quotaData), and the resilience-settings defaults the - // modal renders as placeholders. Kept as separate slices instead of - // mutating the connection object — the window list is UI state, not part - // of the domain. + // Per-group bulk-refresh state; one spinner per provider key. + const [refreshingGroups, setRefreshingGroups] = useState>(new Set()); + + const lastFetchTimeRef = useRef>({}); + const staleProbeRef = useRef>({}); const [cutoffModalConn, setCutoffModalConn] = useState(null); const [cutoffModalWindows, setCutoffModalWindows] = useState([]); const [providerWindowDefaults, setProviderWindowDefaults] = useState< @@ -273,9 +231,6 @@ export default function ProviderLimits() { >({}); const [globalThresholdDefault, setGlobalThresholdDefault] = useState(98); - // Load the resilience-settings defaults once. The endpoint also returns a - // per-provider window registry but we ignore it here — the modal uses the - // connection's live quota cache for window discovery instead. useEffect(() => { let alive = true; fetch("/api/providers/quota-windows") @@ -326,29 +281,32 @@ export default function ProviderLimits() { } }, []); - const applyCachedQuotaState = useCallback((connectionList, caches) => { - const nextQuotaData = {}; - const nextLastRefreshedAt = {}; + const applyCachedQuotaState = useCallback( + (connectionList: any[], caches: Record) => { + const nextQuotaData: Record = {}; + const nextLastRefreshedAt: Record = {}; - for (const conn of connectionList) { - const cached = caches?.[conn.id]; - if (!cached) continue; + for (const conn of connectionList) { + const cached = caches?.[conn.id]; + if (!cached) continue; - nextQuotaData[conn.id] = { - quotas: parseQuotaData(conn.provider, cached), - plan: cached.plan || null, - message: cached.message || null, - raw: cached, - }; + nextQuotaData[conn.id] = { + quotas: parseQuotaData(conn.provider, cached), + plan: cached.plan || null, + message: cached.message || null, + raw: cached, + }; - if (cached.fetchedAt) { - nextLastRefreshedAt[conn.id] = cached.fetchedAt; + if (cached.fetchedAt) { + nextLastRefreshedAt[conn.id] = cached.fetchedAt; + } } - } - setQuotaData(nextQuotaData); - setLastRefreshedAt(nextLastRefreshedAt); - }, []); + setQuotaData(nextQuotaData); + setLastRefreshedAt(nextLastRefreshedAt); + }, + [] + ); const fetchCachedProviderLimits = useCallback(async () => { try { @@ -362,13 +320,12 @@ export default function ProviderLimits() { }, []); const fetchQuota = useCallback( - async (connectionId, provider, options: { force?: boolean } = {}) => { + async (connectionId: string, provider: string, options: { force?: boolean } = {}) => { const force = options?.force === true; - // Debounce: skip if last fetch was < MIN_FETCH_INTERVAL_MS ago const now = Date.now(); const lastFetch = lastFetchTimeRef.current[connectionId] || 0; if (!force && now - lastFetch < MIN_FETCH_INTERVAL_MS) { - return; // Skip, data is still fresh + return; } lastFetchTimeRef.current[connectionId] = now; @@ -392,9 +349,7 @@ export default function ProviderLimits() { const data = await response.json(); const parsedQuotas = parseQuotaData(provider, data); - // T13: If resetAt already passed but provider still returned stale cumulative usage, - // display 0 immediately and trigger a background probe to refresh snapshot. - const hasStaleAfterReset = parsedQuotas.some((q) => q?.staleAfterReset === true); + const hasStaleAfterReset = parsedQuotas.some((q: any) => q?.staleAfterReset === true); if (hasStaleAfterReset) { const lastProbeAt = staleProbeRef.current[connectionId] || 0; if (Date.now() - lastProbeAt >= MIN_FETCH_INTERVAL_MS) { @@ -419,7 +374,7 @@ export default function ProviderLimits() { ...prev, [connectionId]: new Date().toISOString(), })); - } catch (error) { + } catch (error: any) { setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to fetch quota", @@ -432,7 +387,7 @@ export default function ProviderLimits() { ); const refreshProvider = useCallback( - async (connectionId, provider) => { + async (connectionId: string, provider: string) => { await fetchQuota(connectionId, provider, { force: true }); }, [fetchQuota] @@ -463,6 +418,31 @@ export default function ProviderLimits() { } }, [applyCachedQuotaState, fetchConnections]); + // Bulk refresh all accounts inside one provider group. The per-account + // loading indicator is updated by each fetchQuota call; the group spinner + // is just a wrapper that flips while the Promise.all is in flight. + const refreshProviderGroup = useCallback( + async (providerKey: string, accountIds: string[]) => { + setRefreshingGroups((prev) => { + if (prev.has(providerKey)) return prev; + const next = new Set(prev); + next.add(providerKey); + return next; + }); + try { + await Promise.all(accountIds.map((id) => fetchQuota(id, providerKey, { force: true }))); + } finally { + setRefreshingGroups((prev) => { + if (!prev.has(providerKey)) return prev; + const next = new Set(prev); + next.delete(providerKey); + return next; + }); + } + }, + [fetchQuota] + ); + useEffect(() => { const init = async () => { setInitialLoading(true); @@ -489,28 +469,13 @@ export default function ProviderLimits() { ); const sortedConnections = useMemo(() => { - const priority = { - antigravity: 1, - "gemini-cli": 2, - github: 3, - codex: 4, - claude: 5, - kiro: 6, - glm: 7, - zai: 8, - glmt: 9, - "kimi-coding": 10, - minimax: 11, - "minimax-cn": 12, - nanogpt: 13, - }; return [...filteredConnections].sort( - (a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9) + (a, b) => (PROVIDER_ORDER[a.provider] || 99) - (PROVIDER_ORDER[b.provider] || 99) ); }, [filteredConnections]); const resolvedPlanByConnection = useMemo(() => { - const out = {}; + const out: Record = {}; for (const conn of sortedConnections) { out[conn.id] = resolvePlanValue(quotaData[conn.id]?.plan, conn.providerSpecificData); } @@ -518,7 +483,7 @@ export default function ProviderLimits() { }, [sortedConnections, quotaData]); const tierByConnection = useMemo(() => { - const out = {}; + const out: Record> = {}; for (const conn of sortedConnections) { out[conn.id] = normalizePlanTier(resolvedPlanByConnection[conn.id]); } @@ -526,7 +491,7 @@ export default function ProviderLimits() { }, [sortedConnections, resolvedPlanByConnection]); const tierCounts = useMemo(() => { - const counts = { + const counts: Record = { all: sortedConnections.length, enterprise: 0, team: 0, @@ -591,9 +556,27 @@ export default function ProviderLimits() { return counts; }, [sortedConnections, statusByConnection]); - // Apply tier + purchase-type + status filters together, then sort with - // "expiring first" so critical accounts surface at the top regardless of - // alphabetical/priority ordering. + // Unique env tags from connections.providerSpecificData.tag — drives the + // env chip filter. If no tag is set on any connection, the row hides. + const envTags = useMemo(() => { + const tags = new Set(); + for (const conn of sortedConnections) { + const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim(); + if (tag) tags.add(tag); + } + return [...tags].sort((a, b) => a.localeCompare(b)); + }, [sortedConnections]); + + const envCounts = useMemo(() => { + const counts: Record = { all: sortedConnections.length }; + for (const conn of sortedConnections) { + const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; + if (!tag) continue; + counts[tag] = (counts[tag] || 0) + 1; + } + return counts; + }, [sortedConnections]); + const visibleConnections = useMemo(() => { const filtered = sortedConnections.filter((conn) => { const tierKey = tierByConnection[conn.id]?.key || "unknown"; @@ -601,10 +584,16 @@ export default function ProviderLimits() { if (purchaseTypeFilter !== "all" && purchaseTypeByConnection[conn.id] !== purchaseTypeFilter) return false; if (statusFilter !== "all" && statusByConnection[conn.id] !== statusFilter) return false; + if (envFilter !== "all") { + const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; + if (tag !== envFilter) return false; + } return true; }); - // Sort: critical → alert → ok → empty; within tier, soonest reset first. + // Inside each group we still want "critical first, then alert, then ok, + // then empty; tiebreak by soonest reset". Provider order between groups + // is enforced separately via PROVIDER_ORDER. const statusRank: Record = { critical: 0, alert: 1, @@ -628,43 +617,20 @@ export default function ProviderLimits() { purchaseTypeByConnection, statusFilter, statusByConnection, + envFilter, quotaData, ]); - const groupedConnections = useMemo(() => { - if (groupBy !== "environment") return null; - const groups = new Map(); - for (const conn of visibleConnections) { - const key = (conn.providerSpecificData?.tag as string | undefined)?.trim() || t("ungrouped"); - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(conn); - } - - // Convert to sorted array based on tag string (ungrouped at the end) - const sortedGroups = new Map( - [...groups.entries()].sort(([a], [b]) => { - if (a === t("ungrouped")) return 1; - if (b === t("ungrouped")) return -1; - return a.localeCompare(b); - }) + // Group visible connections by provider, then resort group keys by + // PROVIDER_ORDER so the section sequence on the page is stable. + const providerGroups = useMemo(() => { + const groups = groupConnectionsByProvider(visibleConnections); + return new Map( + [...groups.entries()].sort( + ([a], [b]) => (PROVIDER_ORDER[a] || 99) - (PROVIDER_ORDER[b] || 99) + ) ); - - return sortedGroups; - }, [groupBy, visibleConnections, t]); - - const handleSetGroupBy = (value: "none" | "environment") => { - setGroupBy(value); - localStorage.setItem(LS_GROUP_BY, value); - }; - - const toggleGroup = (groupName: string) => { - setExpandedGroups((prev) => { - const next = new Set(prev); - next.has(groupName) ? next.delete(groupName) : next.add(groupName); - localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...next])); - return next; - }); - }; + }, [visibleConnections]); const toggleRow = useCallback((connectionId: string) => { setExpandedRows((prev) => { @@ -697,27 +663,14 @@ export default function ProviderLimits() { } }, []); - // Default inteligente: se não há preferência salva e há connections com grupo, abre em Por Ambiente - useEffect(() => { - if (typeof window === "undefined") return; - const hasSaved = localStorage.getItem(LS_GROUP_BY) !== null; - if ( - !hasSaved && - connections.some((c) => (c.providerSpecificData?.tag as string | undefined)?.trim()) - ) { - setGroupBy("environment"); + const handleSetEnvFilter = useCallback((value: string) => { + setEnvFilter(value); + try { + localStorage.setItem(LS_ENV_FILTER, value); + } catch { + /* ignore */ } - }, [connections]); - - // Quando entra em modo environment pela primeira vez sem estado salvo, abre todos os grupos - useEffect(() => { - if (groupBy !== "environment" || !groupedConnections) return; - if (expandedGroups.size === 0) { - const allGroups = new Set([...groupedConnections.keys()]); - setExpandedGroups(allGroups); - localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...allGroups])); - } - }, [groupBy, groupedConnections]); // eslint-disable-line react-hooks/exhaustive-deps + }, []); if (initialLoading) { return ( @@ -756,49 +709,21 @@ export default function ProviderLimits() {
-
- {/* Group by toggle */} -
- - -
- - -
+ refresh + + {t("refreshAll")} +
- {/* Summary Stats — clickable filters by status */} + {/* Summary stats — clickable status filter */}
{(["all", "critical", "alert", "ok"] as StatusKey[]).map((key) => { const tone = STATUS_TONE[key]; @@ -844,7 +769,7 @@ export default function ProviderLimits() { })}
- {/* Purchase Type Filter */} + {/* Purchase Type filter */}
{tr("filterPurchaseTypeLabel", "Type")} @@ -873,7 +798,7 @@ export default function ProviderLimits() { })}
- {/* Tier Filters */} + {/* Tier filter */}
{tr("filterTierLabel", "Tier")} @@ -894,503 +819,48 @@ export default function ProviderLimits() { color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)", }} > - {tier.label || t(tier.labelKey)} + {tier.label || t(tier.labelKey!)} {tierCounts[tier.key] || 0} ); })}
- {/* Account rows — expandable */} -
- {(() => { - // Compact "chip" representation of a quota for the collapsed row. - // Keeps the row visually predictable regardless of how many quotas - // a provider exposes (DeepSeek 1 chip vs Antigravity 3 chips). - const renderQuotaChips = (quotas: any[]) => { - const MAX = 5; - const visible = quotas.slice(0, MAX); - const extras = quotas.length - visible.length; + {/* Env filter — only renders when at least one connection has a tag */} + {envTags.length > 0 && ( +
+ + {tr("filterEnvLabel", "Env")} + + {(["all", ...envTags] as string[]).map((tag) => { + const count = envCounts[tag] || 0; + const active = envFilter === tag; + const label = tag === "all" ? tr("filterEnvAll", "All") : tag; return ( -
- {visible.map((q, i) => { - 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 ( - - 🪙 {sym} - {amount} - - ); - } - const pctRaw = q.unlimited - ? 100 - : (q.remainingPercentage ?? calculatePercentage(q.used, q.total)); - const pct = Math.round(pctRaw); - const colors = getBarColor(pct); - const shortName = q.displayName || formatQuotaLabel(q.name); - return ( - - {shortName} - {pct}% - - ); - })} - {extras > 0 && ( - +{extras} - )} -
- ); - }; - - // Full quota bar for the expanded panel: large, with countdown and - // a status badge. Reused for credits via a branch on isCredits. - 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 ( -
-
- - paid - -
-
- {formatQuotaLabel(q.name) || tr("creditsLabel", "Credits")} -
-
- {tr("creditBalanceHint", "Remaining balance")} -
-
-
-
- {sym} - {amount} -
-
- ); - } - 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 ( -
-
-
- - {shortName} - - {q.unlimited && ( - - {tr("unlimitedLabel", "Unlimited")} - - )} - {showUsage && ( - - {usedNum.toLocaleString()} / {totalNum.toLocaleString()} - - )} -
-
- {staleAfterReset ? ( - - ⟳ {tr("refreshing", "Refreshing")} - - ) : cd ? ( - - ⏱ {tr("resetsIn", "Resets in")} {cd} - - ) : null} - - {pct}% - -
-
-
-
-
-
- ); - }; - - const renderRow = (conn, isLast) => { - const quota = quotaData[conn.id]; - const isLoading = loading[conn.id]; - const error = errors[conn.id]; - const config = PROVIDER_CONFIG[conn.provider] || { - label: conn.provider, - color: "#666", - }; - const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null); - const resolvedPlan = resolvedPlanByConnection[conn.id]; - const refreshedAt = lastRefreshedAt[conn.id]; - const isExpanded = expandedRows.has(conn.id); - const status = statusByConnection[conn.id] || "empty"; - const statusTone = STATUS_TONE[status]; - - const overrides = (conn.quotaWindowThresholds || null) as Record | 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; - } - - return ( -
handleSetEnvFilter(tag)} + className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer" style={{ - borderBottom: !isLast || isExpanded ? "1px solid var(--color-border)" : "none", + border: active + ? "1px solid var(--color-primary, #E54D5E)" + : "1px solid var(--color-border)", + background: active ? "rgba(229,77,94,0.1)" : "transparent", + color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)", }} > - {/* Collapsed row — clickable to expand. Uses div+role=button - because the row hosts other interactive controls (cutoff - button, refresh, etc.) which would be invalid HTML nested - inside a real - -
- - ) : ( -
{t("noQuotaData")}
- )} -
- )} -
+ {label} + {count} + ); - }; - - if (groupedConnections) { - const entries = [...groupedConnections.entries()]; - return entries.map(([groupName, conns]) => ( -
- - {expandedGroups.has(groupName) && ( -
{conns.map((conn, idx) => renderRow(conn, idx === conns.length - 1))}
- )} -
- )); - } - - return visibleConnections.map((conn, idx) => - renderRow(conn, idx === visibleConnections.length - 1) - ); - })()} + })} +
+ )} + {/* Provider groups */} +
{visibleConnections.length === 0 && ( -
+
{t("noAccountsForTierFilter")}{" "} {(() => { @@ -1401,6 +871,76 @@ export default function ProviderLimits() { .
)} + + {[...providerGroups.entries()].map(([providerKey, conns]) => { + // The group schema reflects the union of quotas across accounts so + // an account that only has a session still lines up under the + // session column even when its siblings also have weekly. We then + // resolve per-row schemas using the same column *keys* so missing + // windows render as em-dash cells. + const allQuotas = conns.flatMap((c) => quotaData[c.id]?.quotas || []); + const groupSchema = getProviderColumns(providerKey, allQuotas); + const grid = buildGridTemplate(groupSchema.columns.length); + const accountIds = conns.map((c) => c.id); + const worstGroupStatus = aggregateWorst( + conns.map((c) => statusByConnection[c.id] || "empty") + ); + + return ( + refreshProviderGroup(providerKey, accountIds)} + > + {conns.map((conn, idx) => { + const rowQuotas = quotaData[conn.id]?.quotas || []; + const rowSchema = getProviderColumns(providerKey, rowQuotas); + // Align each row's column array with the group header by key. + // Missing windows on a row → null-quota cell; this keeps the + // grid columns aligned even when accounts diverge. + const rowColumns = groupSchema.columns.map((groupCol) => { + const match = rowSchema.columns.find((c) => c.key === groupCol.key); + return match || { ...groupCol, quota: null }; + }); + return ( + toggleRow(conn.id)} + onRefresh={() => refreshProvider(conn.id, conn.provider)} + onOpenCutoff={() => { + const windows = (quotaData[conn.id]?.quotas || []).filter( + (q: any) => q && typeof q.name === "string" && !q.isCredits + ); + setCutoffModalWindows(windows); + setCutoffModalConn(conn); + }} + isLast={idx === conns.length - 1} + /> + ); + })} + + ); + })}
{cutoffModalConn && ( @@ -1427,8 +967,6 @@ export default function ProviderLimits() { globalDefaultPercent={globalThresholdDefault} onSave={async (patch) => { await saveQuotaWindowThresholds(cutoffModalConn.id, patch); - // Reflect the new state in the modal-open connection ref so the - // button summary updates without closing/reopening. setCutoffModalConn((prev: any) => { if (!prev) return prev; if (patch === null) return { ...prev, quotaWindowThresholds: null }; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/providerColumns.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/providerColumns.ts new file mode 100644 index 0000000000..a55a486166 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/providerColumns.ts @@ -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 = { + 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( + connections: T[] +): Map { + const groups = new Map(); + for (const conn of connections) { + const key = conn.provider || "unknown"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(conn); + } + return groups; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index cdc0e748b1..c4dfc5b7dd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/tests/unit/provider-columns.test.ts b/tests/unit/provider-columns.test.ts new file mode 100644 index 0000000000..eed1c56d09 --- /dev/null +++ b/tests/unit/provider-columns.test.ts @@ -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"] + ); +});