feat(quota+providers): card-grid layout, provider group headers, Codex race fix (#2667)

Integrated into release/v3.8.3
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-24 16:03:18 -03:00
committed by GitHub
parent f030b007ac
commit 0ead4cbb54
17 changed files with 1205 additions and 972 deletions

View File

@@ -984,6 +984,7 @@ export default function ProvidersPage() {
</Button>
</div>
</div>
<p className="text-sm text-text-muted -mt-2">{t("compatibleProvidersDesc")}</p>
{compatibleProviders.length === 0 &&
anthropicCompatibleProviders.length === 0 &&
ccCompatibleProviders.length === 0 ? (
@@ -992,7 +993,7 @@ export default function ProvidersPage() {
<span>{t("noCompatibleYet")}</span>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{compatibleProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1060,7 +1061,8 @@ export default function ProvidersPage() {
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("oauthProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{oauthProviderEntries
.filter((e) => !IDE_PROVIDER_IDS.has(e.providerId))
.map(({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
@@ -1115,7 +1117,7 @@ export default function ProvidersPage() {
{t("noIdeProviders") || "No IDE providers match the current filters."}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{ideProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1161,7 +1163,8 @@ export default function ProvidersPage() {
{testingMode === "web-cookie" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("webCookieProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{webCookieProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<ProviderCard
key={providerId}
@@ -1204,7 +1207,7 @@ export default function ProvidersPage() {
{testingMode === "free" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{freeSectionEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1247,12 +1250,13 @@ export default function ProvidersPage() {
{testingMode === "apikey" ? t("testing") : t("testAll")}
</button>
</div>
<p className="text-sm text-text-muted -mt-2">{t("apiKeyProvidersDesc")}</p>
{llmProviderEntries.length > 0 && (
<div className="flex flex-col gap-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("llmProviders")}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{llmProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1298,7 +1302,8 @@ export default function ProvidersPage() {
{testingMode === "no-auth" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("noAuthProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{noAuthEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<ProviderCard
key={providerId}
@@ -1341,7 +1346,8 @@ export default function ProvidersPage() {
{testingMode === "upstream-proxy" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("upstreamProxyProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<ProviderCard
key={providerId}
@@ -1366,7 +1372,8 @@ export default function ProvidersPage() {
<ProviderCountBadge {...countConfigured(webFetchEntriesAll)} />
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("webFetchProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{webFetchEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1386,12 +1393,18 @@ export default function ProvidersPage() {
{/* Aggregators Gateways */}
{showSection("apikey") && aggregatorProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<h2 className="text-base font-semibold">{t("aggregatorsGateways")}</h2>
<ProviderCountBadge {...countConfigured(aggregatorProviderEntriesAll)} />
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("aggregatorsGateways")}{" "}
<span
className="size-2.5 rounded-full bg-amber-500"
title={t("aggregatorsGateways")}
/>
<ProviderCountBadge {...countConfigured(aggregatorProviderEntriesAll)} />
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("aggregatorsGatewaysDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{aggregatorProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1411,12 +1424,15 @@ export default function ProvidersPage() {
{/* Enterprise & Cloud */}
{showSection("apikey") && enterpriseProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<h2 className="text-base font-semibold">{t("enterpriseCloud")}</h2>
<ProviderCountBadge {...countConfigured(enterpriseProviderEntriesAll)} />
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("enterpriseCloud")}{" "}
<span className="size-2.5 rounded-full bg-amber-500" title={t("enterpriseCloud")} />
<ProviderCountBadge {...countConfigured(enterpriseProviderEntriesAll)} />
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("enterpriseCloudDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{enterpriseProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1461,7 +1477,8 @@ export default function ProvidersPage() {
{testingMode === "cloud-agent" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("cloudAgentProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{cloudAgentProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<ProviderCard
key={providerId}
@@ -1501,7 +1518,8 @@ export default function ProvidersPage() {
{testingMode === "local" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("localProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<ProviderCard
key={providerId}
@@ -1544,7 +1562,8 @@ export default function ProvidersPage() {
{testingMode === "search" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("searchProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<ProviderCard
key={providerId}
@@ -1562,12 +1581,18 @@ export default function ProvidersPage() {
{/* Embeddings & Rerank */}
{showSection("apikey") && embeddingRerankProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<h2 className="text-base font-semibold">{t("embeddingRerankProviders")}</h2>
<ProviderCountBadge {...countConfigured(embeddingRerankProviderEntriesAll)} />
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("embeddingRerankProviders")}{" "}
<span
className="size-2.5 rounded-full bg-amber-500"
title={t("embeddingRerankProviders")}
/>
<ProviderCountBadge {...countConfigured(embeddingRerankProviderEntriesAll)} />
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("embeddingRerankProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{embeddingRerankProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1587,12 +1612,15 @@ export default function ProvidersPage() {
{/* Image Providers */}
{showSection("apikey") && imageProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<h2 className="text-base font-semibold">{t("imageProviders")}</h2>
<ProviderCountBadge {...countConfigured(imageProviderEntriesAll)} />
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("imageProviders")}{" "}
<span className="size-2.5 rounded-full bg-amber-500" title={t("imageProviders")} />
<ProviderCountBadge {...countConfigured(imageProviderEntriesAll)} />
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("imageProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{imageProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
@@ -1637,7 +1665,8 @@ export default function ProvidersPage() {
{testingMode === "audio" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("audioProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<ProviderCard
key={providerId}
@@ -1655,12 +1684,15 @@ export default function ProvidersPage() {
{/* Video Generation */}
{showSection("apikey") && videoProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<h2 className="text-base font-semibold">{t("videoProviders")}</h2>
<ProviderCountBadge {...countConfigured(videoProviderEntriesAll)} />
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("videoProviders")}{" "}
<span className="size-2.5 rounded-full bg-amber-500" title={t("videoProviders")} />
<ProviderCountBadge {...countConfigured(videoProviderEntriesAll)} />
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
<p className="text-sm text-text-muted -mt-2">{t("videoProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
{videoProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard

View File

@@ -1,631 +0,0 @@
"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", "Edit 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 now")}
</button>
</div>
</div>
</>
) : (
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,162 +0,0 @@
"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,97 @@
"use client";
import { useMemo } from "react";
import Card from "@/shared/components/Card";
import { normalizePlanTier, resolvePlanValue, worstStatus, type CardStatus } from "./utils";
import QuotaCardHeader from "./parts/QuotaCardHeader";
import QuotaCardExpanded from "./parts/QuotaCardExpanded";
const STATUS_BORDER: Record<CardStatus, string> = {
critical: "#ef4444",
alert: "#eab308",
ok: "#22c55e",
empty: "transparent",
};
interface QuotaCardProps {
connection: any;
quota:
| {
quotas?: any[];
plan?: string | null;
message?: string | null;
stale?: { since?: string; reason?: string } | null;
}
| undefined;
loading: boolean;
error: string | null;
refreshedAt?: string;
emailsVisible: boolean;
providerLabel: string;
onRefresh: () => void;
onOpenCutoff: () => void;
}
export default function QuotaCard({
connection,
quota,
loading,
error,
refreshedAt,
emailsVisible,
providerLabel,
onRefresh,
onOpenCutoff,
}: QuotaCardProps) {
const quotas = quota?.quotas ?? [];
const cardStatus = useMemo<CardStatus>(() => worstStatus(quotas), [quotas]);
const tierMeta = useMemo(
() =>
normalizePlanTier(
resolvePlanValue(quota?.plan ?? null, connection.providerSpecificData ?? null)
),
[quota?.plan, connection.providerSpecificData]
);
const resolvedPlan = useMemo(
() => resolvePlanValue(quota?.plan ?? null, connection.providerSpecificData ?? null),
[quota?.plan, connection.providerSpecificData]
);
const overrides = (connection.quotaWindowThresholds as Record<string, number> | null) || null;
const hasOverrides = !!overrides && Object.keys(overrides).length > 0;
const hasStaleData = !!quota?.stale;
const displayRefreshedAt = quota?.stale?.since || refreshedAt;
const canEditCutoff = quotas.some((q: any) => q && typeof q.name === "string" && !q.isCredits);
return (
<Card
padding="none"
className="flex flex-col overflow-hidden"
style={{ borderLeft: `3px solid ${STATUS_BORDER[cardStatus]}` }}
>
<QuotaCardHeader
connection={connection}
providerLabel={providerLabel}
cardStatus={cardStatus}
tierMeta={tierMeta}
resolvedPlan={resolvedPlan}
emailsVisible={emailsVisible}
hasStaleData={hasStaleData}
refreshing={loading}
onRefresh={onRefresh}
onOpenCutoff={onOpenCutoff}
hasCutoffOverrides={hasOverrides}
/>
<QuotaCardExpanded
quotas={quotas}
loading={loading}
error={error}
refreshedAt={displayRefreshedAt}
hasStaleData={hasStaleData}
onRefresh={onRefresh}
onOpenCutoff={onOpenCutoff}
canEditCutoff={canEditCutoff}
/>
</Card>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import QuotaCard from "./QuotaCard";
interface Props {
connections: any[];
quotaData: Record<string, any>;
loading: Record<string, boolean>;
errors: Record<string, string | null>;
lastRefreshedAt: Record<string, string | undefined>;
emailsVisible: boolean;
providerLabels: Record<string, string>;
onRefresh: (id: string, provider: string) => void;
onOpenCutoff: (connection: any) => void;
}
export default function QuotaCardGrid({
connections,
quotaData,
loading,
errors,
lastRefreshedAt,
emailsVisible,
providerLabels,
onRefresh,
onOpenCutoff,
}: Props) {
if (connections.length === 0) return null;
// Group connections by provider, preserving the order from sortedConnections.
const groups = new Map<string, typeof connections>();
for (const conn of connections) {
const list = groups.get(conn.provider) ?? [];
list.push(conn);
groups.set(conn.provider, list);
}
return (
<div className="flex flex-col gap-6">
{[...groups.entries()].map(([provider, conns]) => (
<div key={provider} className="flex flex-col gap-3">
<h3 className="text-sm font-semibold text-text-main flex items-center gap-2">
{providerLabels[provider] || provider}
<span className="text-xs font-normal text-text-muted">
({conns.length} account{conns.length !== 1 ? "s" : ""})
</span>
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{conns.map((conn) => (
<QuotaCard
key={conn.id}
connection={conn}
quota={quotaData[conn.id]}
loading={!!loading[conn.id]}
error={errors[conn.id] || null}
refreshedAt={lastRefreshedAt[conn.id]}
emailsVisible={emailsVisible}
providerLabel={providerLabels[conn.provider] || conn.provider}
onRefresh={() => onRefresh(conn.id, conn.provider)}
onOpenCutoff={() => onOpenCutoff(conn)}
/>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { cn } from "@/shared/utils/cn";
import { getBarColor } from "./utils";
interface Props {
/** Remaining percentage (0-100). Lower = worse. */
percent: number;
/** Visual height — h-1 (4px, default) or h-1.5 (6px). */
size?: "xs" | "sm";
className?: string;
}
export default function QuotaMiniBar({ percent, size = "xs", className }: Props) {
const colors = getBarColor(percent);
const heightCls = size === "sm" ? "h-1.5" : "h-1";
return (
<div
className={cn(
"rounded-full overflow-hidden bg-black/[0.06] dark:bg-white/[0.06]",
heightCls,
className
)}
>
<div
className="h-full rounded-full transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(100, Math.max(0, percent))}%`,
background: colors.bar,
}}
/>
</div>
);
}

View File

@@ -17,12 +17,9 @@ import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import QuotaCutoffModal from "./QuotaCutoffModal";
import ProviderGroup, { buildGridTemplate } from "./ProviderGroup";
import AccountRow from "./AccountRow";
import { getProviderColumns, groupConnectionsByProvider } from "./providerColumns";
import QuotaCardGrid from "./QuotaCardGrid";
import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback";
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";
@@ -192,16 +189,6 @@ export default function ProviderLimits() {
const [initialLoading, setInitialLoading] = useState(true);
const [tierFilter, setTierFilter] = useState("all");
const [expandedRows, setExpandedRows] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set();
try {
const saved = localStorage.getItem(LS_EXPANDED_ROWS);
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
const [purchaseTypeFilter, setPurchaseTypeFilter] = useState<PurchaseTypeKey>(() => {
if (typeof window === "undefined") return "all";
const saved = localStorage.getItem(LS_PURCHASE_FILTER) as PurchaseTypeKey | null;
@@ -219,9 +206,6 @@ export default function ProviderLimits() {
return localStorage.getItem(LS_ENV_FILTER) || "all";
});
// Per-group bulk-refresh state; one spinner per provider key.
const [refreshingGroups, setRefreshingGroups] = useState<Set<string>>(new Set());
const lastFetchTimeRef = useRef<Record<string, number>>({});
const staleProbeRef = useRef<Record<string, number>>({});
const [cutoffModalConn, setCutoffModalConn] = useState<any | null>(null);
@@ -418,31 +402,6 @@ 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);
@@ -621,30 +580,6 @@ export default function ProviderLimits() {
quotaData,
]);
// 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)
)
);
}, [visibleConnections]);
const toggleRow = useCallback((connectionId: string) => {
setExpandedRows((prev) => {
const next = new Set(prev);
next.has(connectionId) ? next.delete(connectionId) : next.add(connectionId);
try {
localStorage.setItem(LS_EXPANDED_ROWS, JSON.stringify([...next]));
} catch {
/* localStorage may be unavailable; persistence is best-effort */
}
return next;
});
}, []);
const handleSetPurchaseFilter = useCallback((value: PurchaseTypeKey) => {
setPurchaseTypeFilter(value);
try {
@@ -872,75 +807,23 @@ export default function ProviderLimits() {
</div>
)}
{[...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 (
<ProviderGroup
key={providerKey}
providerKey={providerKey}
providerLabel={PROVIDER_LABEL[providerKey] || providerKey}
accountCount={conns.length}
worstStatus={worstGroupStatus}
columns={groupSchema.columns}
overflowMax={groupSchema.overflowCount}
isRefreshing={refreshingGroups.has(providerKey)}
onRefreshGroup={() => 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 (
<AccountRow
key={conn.id}
connection={conn}
quota={quotaData[conn.id]}
loading={!!loading[conn.id]}
error={errors[conn.id] || null}
refreshedAt={lastRefreshedAt[conn.id]}
tierMeta={tierByConnection[conn.id] || normalizePlanTier(null)}
resolvedPlan={resolvedPlanByConnection[conn.id]}
status={statusByConnection[conn.id] || "empty"}
statusTone={STATUS_TONE[statusByConnection[conn.id] || "empty"]}
columns={rowColumns}
overflowCount={rowSchema.overflowCount}
isExpanded={expandedRows.has(conn.id)}
emailsVisible={emailsVisible}
gridTemplateColumns={grid}
onToggle={() => 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}
/>
);
})}
</ProviderGroup>
);
})}
<QuotaCardGrid
connections={visibleConnections}
quotaData={quotaData}
loading={loading}
errors={errors}
lastRefreshedAt={lastRefreshedAt}
emailsVisible={emailsVisible}
providerLabels={PROVIDER_LABEL}
onRefresh={refreshProvider}
onOpenCutoff={(conn) => {
const windows = (quotaData[conn.id]?.quotas || []).filter(
(q: any) => q && typeof q.name === "string" && !q.isCredits
);
setCutoffModalWindows(windows);
setCutoffModalConn(conn);
}}
/>
</div>
{cutoffModalConn && (

View File

@@ -0,0 +1,132 @@
"use client";
import { useTranslations } from "next-intl";
import { calculatePercentage, formatQuotaLabel, getBarColor, topQuotas } from "../utils";
import QuotaMiniBar from "../QuotaMiniBar";
const CURRENCY_SYMBOLS: Record<string, string> = {
USD: "$",
CNY: "¥",
EUR: "€",
GBP: "£",
JPY: "¥",
KRW: "₩",
INR: "₹",
};
interface Props {
quotas: any[];
/** When > MAX_VISIBLE, render "+N more" hint. */
maxVisible?: number;
loading: boolean;
error: string | null;
message: string | null;
}
const MAX_VISIBLE_DEFAULT = 3;
function QuotaRow({ q }: { q: any }) {
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 className="flex items-center justify-between gap-2 py-0.5">
<span className="text-[11px] text-text-main truncate flex items-center gap-1">
<span
className="material-symbols-outlined text-[12px] shrink-0"
style={{ color: colors.text }}
>
paid
</span>
{formatQuotaLabel(q.name) || "Credits"}
</span>
<span className="text-[11px] font-bold tabular-nums" 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 label = q.displayName || formatQuotaLabel(q.name);
return (
<div className="flex flex-col gap-0.5 py-0.5" title={q.modelKey || q.name}>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-text-main truncate flex-1 min-w-0">{label}</span>
<span
className="text-[11px] font-bold tabular-nums shrink-0"
style={{ color: colors.text }}
>
{q.unlimited ? "∞" : `${pct}%`}
</span>
</div>
{!q.unlimited && <QuotaMiniBar percent={pct} />}
</div>
);
}
export default function QuotaCardBody({
quotas,
maxVisible = MAX_VISIBLE_DEFAULT,
loading,
error,
message,
}: Props) {
const t = useTranslations("usage");
if (loading) {
return (
<div className="px-3 py-3 text-[11px] text-text-muted flex items-center gap-1.5">
<span className="material-symbols-outlined animate-spin text-[13px]">
progress_activity
</span>
{t("loadingQuotas")}
</div>
);
}
if (error) {
return (
<div className="px-3 py-3 text-[11px] text-red-500 flex items-start gap-1.5">
<span className="material-symbols-outlined text-[13px] mt-0.5">error</span>
<span className="truncate">{error}</span>
</div>
);
}
if ((!quotas || quotas.length === 0) && message) {
return (
<div className="px-3 py-3 text-[11px] text-text-muted italic truncate" title={message}>
{message}
</div>
);
}
if (!quotas || quotas.length === 0) {
return <div className="px-3 py-3 text-[11px] text-text-muted italic">{t("noQuotaData")}</div>;
}
const visible = topQuotas(quotas, maxVisible);
const hidden = Math.max(0, quotas.length - visible.length);
return (
<div className="flex flex-col gap-1 px-3 pb-2">
{visible.map((q, i) => (
<QuotaRow key={`${q.name}-${q.modelKey ?? ""}-${i}`} q={q} />
))}
{hidden > 0 && (
<div className="text-[10px] text-text-muted italic pt-0.5">+{hidden} more</div>
)}
</div>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useTranslations } from "next-intl";
import { calculatePercentage, formatCountdown, formatQuotaLabel, getBarColor } from "../utils";
import QuotaMiniBar from "../QuotaMiniBar";
import { translateUsageOrFallback, type UsageTranslationValues } from "../i18nFallback";
const CURRENCY_SYMBOLS: Record<string, string> = {
USD: "$",
CNY: "¥",
EUR: "€",
GBP: "£",
JPY: "¥",
KRW: "₩",
INR: "₹",
};
interface Props {
quotas: any[];
loading: boolean;
error: string | null;
refreshedAt?: string;
hasStaleData: boolean;
onRefresh: () => void;
onOpenCutoff: () => void;
canEditCutoff: boolean;
}
function QuotaDetailRow({ q }: { q: any }) {
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 className="flex items-center justify-between gap-2 py-1">
<span className="text-[12px] font-medium text-text-main flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]" style={{ color: colors.text }}>
paid
</span>
{formatQuotaLabel(q.name) || "Credits"}
</span>
<span className="text-[12px] font-bold tabular-nums" 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 label = q.displayName || formatQuotaLabel(q.name);
const usedNum = Number(q.used || 0);
const totalNum = Number(q.total || 0);
const showUsage = totalNum > 0 && !q.unlimited;
return (
<div className="flex flex-col gap-1 py-1" title={q.modelKey || q.name}>
<div className="flex items-center justify-between gap-2">
<span className="text-[12px] font-medium text-text-main truncate">{label}</span>
<span
className="text-[12px] font-bold tabular-nums shrink-0"
style={{ color: colors.text }}
>
{q.unlimited ? "∞" : `${pct}%`}
</span>
</div>
{!q.unlimited && <QuotaMiniBar percent={pct} size="sm" />}
<div className="flex items-center justify-between gap-2 text-[10px] text-text-muted tabular-nums">
<span>
{showUsage && (
<>
{usedNum.toLocaleString()} / {totalNum.toLocaleString()}
</>
)}
</span>
{q.staleAfterReset ? (
<span title="Refreshing"></span>
) : cd ? (
<span> reset in {cd}</span>
) : null}
</div>
</div>
);
}
export default function QuotaCardExpanded({
quotas,
loading,
error,
refreshedAt,
hasStaleData,
onRefresh,
onOpenCutoff,
canEditCutoff,
}: Props) {
const t = useTranslations("usage");
const tr = (key: string, fallback: string, values?: UsageTranslationValues) =>
translateUsageOrFallback(t, key, fallback, values);
const refreshedLabel = refreshedAt
? new Date(refreshedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
: null;
return (
<div className="border-t border-border bg-bg-subtle/30 px-3 py-2.5 flex flex-col gap-1.5">
{loading ? (
<div className="text-[11px] text-text-muted flex items-center gap-1.5">
<span className="material-symbols-outlined animate-spin text-[13px]">
progress_activity
</span>
{t("loadingQuotas")}
</div>
) : error ? (
<div className="text-[11px] text-red-500 flex items-start gap-1.5">
<span className="material-symbols-outlined text-[13px]">error</span>
<span>{error}</span>
</div>
) : quotas.length === 0 ? (
<div className="text-[11px] text-text-muted italic">{t("noQuotaData")}</div>
) : (
<div className="flex flex-col divide-y divide-border/40">
{quotas.map((q, i) => (
<QuotaDetailRow key={`${q.name}-${q.modelKey ?? ""}-${i}`} q={q} />
))}
</div>
)}
<div className="flex items-center justify-between gap-2 pt-1.5 border-t border-border/40">
{refreshedLabel && (
<span
className={`text-[10px] tabular-nums ${
hasStaleData ? "text-amber-500" : "text-text-muted"
}`}
title={
hasStaleData
? t("staleQuotaTooltip")
: `${tr("lastRefreshed", "Last refreshed")}: ${refreshedLabel}`
}
>
{tr("updatedShort", "Updated")} {refreshedLabel}
</span>
)}
<div className="flex items-center gap-1.5 ml-auto">
<button
type="button"
disabled={!canEditCutoff}
onClick={(e) => {
e.stopPropagation();
onOpenCutoff();
}}
className="inline-flex items-center gap-1 text-[11px] font-medium px-2 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-[12px]">tune</span>
{tr("editCutoffs", "Edit cutoffs")}
</button>
<button
type="button"
disabled={loading}
onClick={(e) => {
e.stopPropagation();
onRefresh();
}}
className="inline-flex items-center gap-1 text-[11px] font-medium px-2 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-[12px] ${loading ? "animate-spin" : ""}`}
>
refresh
</span>
{tr("forceRefresh", "Refresh now")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { useTranslations } from "next-intl";
import Badge from "@/shared/components/Badge";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import { STATUS_EMOJI, type CardStatus } from "../utils";
interface Props {
connection: any;
providerLabel: string;
cardStatus: CardStatus;
tierMeta: { key: string; label: string; variant: any };
resolvedPlan: string | null;
emailsVisible: boolean;
hasStaleData: boolean;
/** Disabled when loading. */
refreshing: boolean;
onRefresh: () => void;
onOpenCutoff: () => void;
hasCutoffOverrides: boolean;
}
export default function QuotaCardHeader({
connection,
providerLabel,
cardStatus,
tierMeta,
resolvedPlan,
emailsVisible,
hasStaleData,
refreshing,
onRefresh,
onOpenCutoff,
hasCutoffOverrides,
}: Props) {
const t = useTranslations("usage");
const accountName = pickDisplayValue(
[connection.name, connection.displayName, connection.email],
emailsVisible,
connection.provider
);
return (
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-1.5">
<div className="flex items-start gap-2 min-w-0 flex-1">
<span
className="text-[14px] leading-none mt-0.5 shrink-0"
title={cardStatus}
aria-label={cardStatus}
>
{STATUS_EMOJI[cardStatus]}
</span>
<div className="size-6 rounded-md flex items-center justify-center overflow-hidden shrink-0">
<ProviderIcon providerId={connection.provider} size={24} type="color" />
</div>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-1.5 min-w-0">
<span
className="text-[12px] font-semibold text-text-main truncate"
title={providerLabel}
>
{providerLabel}
</span>
<span
title={
resolvedPlan
? t("rawPlanWithValue", { plan: resolvedPlan })
: t("noPlanFromProvider")
}
>
<Badge variant={tierMeta.variant} size="sm" dot className="h-4 leading-none">
{tierMeta.label}
</Badge>
</span>
{hasStaleData && (
<span
className="material-symbols-outlined text-[12px] text-amber-500 shrink-0"
title={t("staleQuotaTooltip")}
>
schedule
</span>
)}
</div>
<span className="text-[11px] text-text-muted truncate" title={accountName ?? ""}>
{accountName}
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenCutoff();
}}
title={t("quotaCutoffsButtonHelp")}
className={`p-1 rounded-md cursor-pointer transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] ${
hasCutoffOverrides ? "text-primary" : "text-text-muted"
}`}
>
<span className="material-symbols-outlined text-[14px]">tune</span>
</button>
<button
type="button"
disabled={refreshing}
onClick={(e) => {
e.stopPropagation();
if (refreshing) return;
onRefresh();
}}
title={t("refreshQuota")}
className="p-1 rounded-md text-text-muted cursor-pointer transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:opacity-40 disabled:cursor-not-allowed"
>
<span
className={`material-symbols-outlined text-[14px] ${refreshing ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import QuotaCardGrid from "../QuotaCardGrid";
interface Props {
tag: string;
connections: any[];
/** All props piped to the underlying QuotaCardGrid */
quotaData: Record<string, any>;
loading: Record<string, boolean>;
errors: Record<string, string | null>;
lastRefreshedAt: Record<string, string | undefined>;
expandedRows: Set<string>;
emailsVisible: boolean;
providerLabels: Record<string, string>;
onToggle: (id: string) => void;
onRefresh: (id: string, provider: string) => void;
onOpenCutoff: (connection: any) => void;
/** Initial open state — defaults to true; UI persistence is out of scope. */
defaultOpen?: boolean;
}
export default function QuotaEnvGroup({
tag,
connections,
defaultOpen = true,
...gridProps
}: Props) {
const [open, setOpen] = useState(defaultOpen);
return (
<details
open={open}
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
className="rounded-lg border border-border bg-surface overflow-hidden"
>
<summary className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-black/[0.02] dark:hover:bg-white/[0.02]">
<span className="material-symbols-outlined text-[14px] text-text-muted">
{open ? "expand_less" : "expand_more"}
</span>
<span className="text-[13px] font-semibold text-text-main">{tag}</span>
<span className="text-[11px] text-text-muted tabular-nums">({connections.length})</span>
</summary>
<div className="px-3 pb-3">
<QuotaCardGrid connections={connections} {...gridProps} />
</div>
</details>
);
}

View File

@@ -548,3 +548,106 @@ export function normalizePlanTier(plan) {
return { key: "unknown", label: titleCased || "Unknown", variant: "default", rank: 0, raw };
}
// === Card Grid Helpers (T7) =================================================
export const STATUS_EMOJI = {
critical: "🔴",
alert: "🟡",
ok: "🟢",
empty: "⚪",
} as const;
export type CardStatus = keyof typeof STATUS_EMOJI;
const QUOTA_BAR_GREEN_THRESHOLD = 50;
const QUOTA_BAR_YELLOW_THRESHOLD = 20;
function quotaRemainingPercent(q: any): number {
if (q?.unlimited) return 100;
if (q?.remainingPercentage !== undefined) return Number(q.remainingPercentage);
return calculatePercentage(q?.used, q?.total);
}
function quotaStatus(q: any): "critical" | "alert" | "ok" {
const pct = quotaRemainingPercent(q);
if (pct <= QUOTA_BAR_YELLOW_THRESHOLD) return "critical";
if (pct <= QUOTA_BAR_GREEN_THRESHOLD) return "alert";
return "ok";
}
export function worstStatus(quotas: any[] | undefined): CardStatus {
if (!quotas || quotas.length === 0) return "empty";
let worst: "ok" | "alert" = "ok";
for (const q of quotas) {
const s = quotaStatus(q);
if (s === "critical") return "critical";
if (s === "alert" && worst === "ok") worst = "alert";
}
return worst;
}
const STATUS_ORDER: Record<"critical" | "alert" | "ok", number> = {
critical: 0,
alert: 1,
ok: 2,
};
export function topQuotas(quotas: any[], n = 3): any[] {
return [...quotas.filter(Boolean)]
.sort((a, b) => {
const sa = STATUS_ORDER[quotaStatus(a)];
const sb = STATUS_ORDER[quotaStatus(b)];
if (sa !== sb) return sa - sb;
return quotaRemainingPercent(a) - quotaRemainingPercent(b);
})
.slice(0, n);
}
export function getBarColor(remainingPercentage: number): {
bar: string;
text: string;
bg: string;
} {
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)" };
}
export function formatCountdown(resetAt: string | null | undefined): string | null {
if (!resetAt) return null;
try {
const diff = new Date(resetAt).getTime() - Date.now();
if (diff <= 0) return null;
const h = Math.floor(diff / 3_600_000);
const m = Math.floor((diff % 3_600_000) / 60_000);
if (h >= 24) {
const d = Math.floor(h / 24);
return `${d}d ${h % 24}h ${m}m`;
}
return `${h}h ${m}m`;
} catch {
return null;
}
}
export function getNextResetSummary(quotas: any[] | undefined): string | null {
if (!quotas || quotas.length === 0) return null;
const now = Date.now();
let soonest = Number.POSITIVE_INFINITY;
let soonestIso: string | null = null;
for (const q of quotas) {
if (!q?.resetAt) continue;
const ts = new Date(q.resetAt).getTime();
if (!Number.isFinite(ts) || ts <= now) continue;
if (ts < soonest) {
soonest = ts;
soonestIso = typeof q.resetAt === "string" ? q.resetAt : new Date(ts).toISOString();
}
}
return soonestIso ? formatCountdown(soonestIso) : null;
}

View File

@@ -3937,7 +3937,23 @@
"providerDetailPathAutoDetected": "Path is auto-detected per OS (Linux/Mac).",
"webFetch": "Web Fetch",
"webFetchTooltip": "Providers that extract content from web URLs (HTML → Markdown, scrape, screenshot)",
"webFetchProvidersHeading": "Web Fetch Providers"
"webFetchProvidersHeading": "Web Fetch Providers",
"compatibleProvidersDesc": "OpenAI-compatible and Anthropic-compatible endpoints you host or configure. Point any OpenAI SDK to your own URL and route requests here.",
"oauthProvidersDesc": "Providers authenticated via OAuth — sign in once and OmniRoute handles token rotation automatically.",
"webCookieProvidersDesc": "Providers that authenticate via browser session cookies. Credentials are synced by extracting them from the browser's storage.",
"apiKeyProvidersDesc": "Standard API key providers. Add your key once and OmniRoute routes, retries, and rate-limits on your behalf.",
"noAuthProvidersDesc": "Open endpoints that require no credentials — ready to use immediately without any sign-up.",
"upstreamProxyProvidersDesc": "Route outbound traffic through an upstream proxy before it reaches the provider. Useful for corporate networks or traffic inspection.",
"webFetchProvidersDesc": "Providers that fetch and extract content from web URLs. Use them to ground prompts with live web data.",
"aggregatorsGatewaysDesc": "Multi-provider aggregators and AI gateways that expose a single unified API across dozens of underlying models.",
"enterpriseCloudDesc": "Enterprise-tier and cloud-hosted models with enhanced SLAs, compliance certifications, and dedicated capacity.",
"cloudAgentProvidersDesc": "Autonomous cloud agents that execute long-running tasks with plan approval and live status tracking.",
"localProvidersDesc": "Self-hosted models running on your own hardware. No data leaves your infrastructure.",
"searchProvidersDesc": "Web and document search providers. Attach them to LLM calls for Retrieval-Augmented Generation.",
"audioProvidersDesc": "Text-to-speech and speech-to-text providers for voice I/O and audio transcription pipelines.",
"embeddingRerankProvidersDesc": "Vector embedding and reranking providers for semantic search, RAG pipelines, and similarity scoring.",
"imageProvidersDesc": "Image generation and vision providers — create images from text or analyse existing ones.",
"videoProvidersDesc": "Video generation providers. Create short video clips from text prompts or images."
},
"settings": {
"title": "Settings",
@@ -5448,6 +5464,9 @@
"rawPlanWithValue": "Raw plan: {plan}",
"noPlanFromProvider": "No plan from provider",
"noQuotaData": "No quota data",
"cardExpand": "Expand",
"cardCollapse": "Collapse",
"moreQuotas": "+{count} more",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",

View File

@@ -3934,7 +3934,23 @@
"providerDetailPathAutoDetected": "O caminho é detectado automaticamente por SO (Linux/Mac).",
"webFetch": "Web Fetch",
"webFetchTooltip": "Provedores que extraem conteúdo de URLs (HTML → Markdown, scraping, screenshot)",
"webFetchProvidersHeading": "Provedores Web Fetch"
"webFetchProvidersHeading": "Provedores Web Fetch",
"compatibleProvidersDesc": "Endpoints compatíveis com OpenAI ou Anthropic que você mesmo hospeda ou configura. Aponte qualquer SDK OpenAI para sua URL e roteie as requisições aqui.",
"oauthProvidersDesc": "Provedores autenticados via OAuth — faça login uma vez e o OmniRoute cuida da renovação automática dos tokens.",
"webCookieProvidersDesc": "Provedores que se autenticam via cookies de sessão do navegador. As credenciais são sincronizadas extraindo-as do armazenamento do browser.",
"apiKeyProvidersDesc": "Provedores com API key padrão. Adicione sua chave uma vez e o OmniRoute gerencia roteamento, retries e rate-limiting por você.",
"noAuthProvidersDesc": "Endpoints abertos que não exigem credenciais — prontos para uso imediato sem cadastro.",
"upstreamProxyProvidersDesc": "Roteie o tráfego de saída por um proxy antes de chegar ao provedor. Útil para redes corporativas ou inspeção de tráfego.",
"webFetchProvidersDesc": "Provedores que buscam e extraem conteúdo de URLs. Use-os para fundamentar prompts com dados da web em tempo real.",
"aggregatorsGatewaysDesc": "Agregadores multi-provedor e gateways de IA que expõem uma API unificada para dezenas de modelos subjacentes.",
"enterpriseCloudDesc": "Modelos enterprise e hospedados em nuvem com SLAs aprimorados, certificações de conformidade e capacidade dedicada.",
"cloudAgentProvidersDesc": "Agentes cloud autônomos que executam tarefas de longa duração com aprovação de plano e rastreamento de status em tempo real.",
"localProvidersDesc": "Modelos auto-hospedados rodando no seu próprio hardware. Nenhum dado sai da sua infraestrutura.",
"searchProvidersDesc": "Provedores de busca web e documental. Conecte-os a chamadas LLM para Geração com Recuperação Aumentada (RAG).",
"audioProvidersDesc": "Provedores de síntese e transcrição de voz para pipelines de I/O por áudio.",
"embeddingRerankProvidersDesc": "Provedores de embeddings vetoriais e reranking para busca semântica, pipelines RAG e pontuação de similaridade.",
"imageProvidersDesc": "Provedores de geração e visão de imagens — crie imagens a partir de texto ou analise imagens existentes.",
"videoProvidersDesc": "Provedores de geração de vídeo. Crie clipes curtos a partir de prompts de texto ou imagens."
},
"settings": {
"title": "Configurações",
@@ -5445,6 +5461,9 @@
"rawPlanWithValue": "Plano bruto: {plan}",
"noPlanFromProvider": "Sem plano do provedor",
"noQuotaData": "Sem dados de cota",
"cardExpand": "Expandir",
"cardCollapse": "Recolher",
"moreQuotas": "+{count} mais",
"ungrouped": "Sem grupo",
"viewFlat": "Lista",
"viewByEnvironment": "Por Ambiente",

View File

@@ -177,12 +177,22 @@ export async function checkAndRefreshToken(provider: string, credentials: any) {
const connectionId: string | undefined = updatedCredentials.connectionId;
const newCredentials = connectionId
? await withConnectionRefreshMutex(connectionId, () =>
getAccessToken(provider, updatedCredentials)
)
? await withConnectionRefreshMutex(connectionId, async () => {
const result = await getAccessToken(provider, updatedCredentials);
if (result?.accessToken) {
// Persist BEFORE the mutex releases so a concurrent request
// cannot read stale DB credentials and re-use a rotated refresh token.
await updateProviderCredentials(connectionId, result);
}
return result;
})
: await getAccessToken(provider, updatedCredentials);
if (newCredentials && newCredentials.accessToken) {
await updateProviderCredentials(updatedCredentials.connectionId, newCredentials);
// DB already updated inside the mutex when connectionId is present.
// For the no-connectionId path, persist here as before.
if (!connectionId) {
await updateProviderCredentials(updatedCredentials.connectionId, newCredentials);
}
updatedCredentials = {
...updatedCredentials,

View File

@@ -0,0 +1,132 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
STATUS_EMOJI,
worstStatus,
topQuotas,
getNextResetSummary,
getBarColor,
formatCountdown,
} from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils";
const mkQuota = (overrides: Record<string, unknown> = {}) => ({
name: "session",
used: 0,
total: 100,
resetAt: null,
...overrides,
});
test("STATUS_EMOJI maps all four statuses", () => {
assert.equal(STATUS_EMOJI.critical, "🔴");
assert.equal(STATUS_EMOJI.alert, "🟡");
assert.equal(STATUS_EMOJI.ok, "🟢");
assert.equal(STATUS_EMOJI.empty, "⚪");
});
test("worstStatus returns empty for no quotas", () => {
assert.equal(worstStatus([]), "empty");
});
test("worstStatus returns ok when all healthy (>50%)", () => {
const quotas = [
mkQuota({ used: 10, total: 100 }), // 90% remaining
mkQuota({ used: 5, total: 100 }), // 95% remaining
];
assert.equal(worstStatus(quotas), "ok");
});
test("worstStatus returns alert when any is 20-50%", () => {
const quotas = [
mkQuota({ used: 10, total: 100 }), // 90%
mkQuota({ used: 70, total: 100 }), // 30% — alert
];
assert.equal(worstStatus(quotas), "alert");
});
test("worstStatus returns critical when any is <=20%", () => {
const quotas = [
mkQuota({ used: 10, total: 100 }), // 90%
mkQuota({ used: 90, total: 100 }), // 10% — critical
];
assert.equal(worstStatus(quotas), "critical");
});
test("topQuotas returns up to N sorted by worst first", () => {
const q = [
mkQuota({ name: "a", used: 10, total: 100 }), // ok 90
mkQuota({ name: "b", used: 95, total: 100 }), // critical 5
mkQuota({ name: "c", used: 70, total: 100 }), // alert 30
mkQuota({ name: "d", used: 50, total: 100 }), // alert 50
];
const top3 = topQuotas(q, 3);
assert.equal(top3.length, 3);
assert.equal(top3[0].name, "b"); // critical first
assert.equal(top3[1].name, "c"); // then worst alert
});
test("getNextResetSummary returns countdown of soonest future reset", () => {
const now = Date.now();
const future1h = new Date(now + 3_600_000).toISOString();
const future1d = new Date(now + 86_400_000).toISOString();
const past = new Date(now - 1_000).toISOString();
const summary = getNextResetSummary([
mkQuota({ resetAt: future1d }),
mkQuota({ resetAt: future1h }),
mkQuota({ resetAt: past }),
]);
assert.ok(summary && (summary.includes("1h") || summary.includes("59m")));
});
test("getNextResetSummary returns null when no future resets", () => {
assert.equal(getNextResetSummary([mkQuota({ resetAt: null })]), null);
assert.equal(getNextResetSummary(undefined), null);
assert.equal(getNextResetSummary([]), null);
});
test("getBarColor returns red below 20%", () => {
const c = getBarColor(15);
assert.equal(c.bar, "#ef4444");
});
test("getBarColor returns yellow between 20-50%", () => {
const c = getBarColor(35);
assert.equal(c.bar, "#eab308");
});
test("getBarColor returns green above 50%", () => {
const c = getBarColor(80);
assert.equal(c.bar, "#22c55e");
});
test("formatCountdown returns null for null/empty/past", () => {
assert.equal(formatCountdown(null), null);
assert.equal(formatCountdown(undefined), null);
const past = new Date(Date.now() - 1000).toISOString();
assert.equal(formatCountdown(past), null);
});
test("formatCountdown returns h+m for sub-day intervals", () => {
const future = new Date(Date.now() + 2 * 3_600_000 + 30 * 60_000).toISOString();
const out = formatCountdown(future);
assert.ok(out && out.includes("2h"));
});
test("formatCountdown returns d+h+m for multi-day intervals", () => {
const future = new Date(Date.now() + 2 * 86_400_000 + 5 * 3_600_000 + 30 * 60_000).toISOString();
const out = formatCountdown(future);
assert.match(out!, /^2d \d+h \d+m$/);
});
test("topQuotas filters out null/undefined entries", () => {
const q = [
null,
mkQuota({ name: "a", used: 10, total: 100 }),
undefined,
mkQuota({ name: "b", used: 95, total: 100 }),
];
const top = topQuotas(q as any[], 3);
assert.equal(top.length, 2);
assert.equal(top[0].name, "b");
assert.equal(top[1].name, "a");
});

View File

@@ -0,0 +1,134 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
/**
* Structural test: locks the invariant that updateProviderCredentials is
* called INSIDE the withConnectionRefreshMutex closure in checkAndRefreshToken.
*
* Background: with rotating refresh tokens (OpenAI/Codex), if the DB write
* happens AFTER the mutex releases, a concurrent request can read stale
* credentials and send the old refresh token. OpenAI detects this as
* refresh_token_reused and permanently revokes the entire token family.
*
* This test parses the source text of tokenRefresh.ts and verifies that
* the mutex closure body contains the DB write call. It is intentionally
* a source-level check so it survives refactors that would re-introduce
* the race (e.g., moving the write back outside).
*/
test("tokenRefresh.ts wraps updateProviderCredentials inside withConnectionRefreshMutex", async () => {
const srcPath = path.resolve("src/sse/services/tokenRefresh.ts");
const source = await readFile(srcPath, "utf8");
// Locate the withConnectionRefreshMutex invocation in checkAndRefreshToken.
// Match: withConnectionRefreshMutex(connectionId, async () => { ... })
// The regex captures everything between the opening { and the matching }
// of the async arrow function passed as the second argument.
//
// We look for the async closure form specifically — if someone reverts to
// the non-async one-liner, this assertion will fire and force a review.
const mutexCallIndex = source.indexOf("withConnectionRefreshMutex(connectionId, async () => {");
assert.ok(
mutexCallIndex !== -1,
"withConnectionRefreshMutex must be called with an async arrow function closure (not a one-liner). " +
"This ensures the DB write can be awaited inside before the mutex releases."
);
// Extract everything from the opening of the async closure to the
// corresponding closing brace + ) that ends the mutex call.
const closureStart = source.indexOf("{", mutexCallIndex);
assert.ok(closureStart !== -1, "Could not locate opening brace of mutex closure");
// Walk forward to find the matching closing brace.
let depth = 0;
let closureEnd = -1;
for (let i = closureStart; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) {
closureEnd = i;
break;
}
}
}
assert.ok(closureEnd !== -1, "Could not locate closing brace of mutex closure");
const closureBody = source.slice(closureStart, closureEnd + 1);
assert.match(
closureBody,
/updateProviderCredentials/,
"updateProviderCredentials DB write MUST be inside the withConnectionRefreshMutex " +
"closure body. Moving it outside creates a race window where a concurrent request " +
"reads stale credentials and re-uses a rotated refresh token, triggering " +
"refresh_token_reused (permanent token family revocation on OpenAI/Codex)."
);
});
/**
* Complementary structural check: the unconditional updateProviderCredentials
* call outside the mutex must be guarded by !connectionId (no-connectionId
* fallback path only). This prevents a double-write that would bypass the
* serialization guarantee.
*/
test("tokenRefresh.ts outer updateProviderCredentials call is guarded by !connectionId", async () => {
const srcPath = path.resolve("src/sse/services/tokenRefresh.ts");
const source = await readFile(srcPath, "utf8");
// Find the checkAndRefreshToken function body.
const fnStart = source.indexOf("export async function checkAndRefreshToken(");
assert.ok(fnStart !== -1, "checkAndRefreshToken function not found in source");
// Walk to find the full function body.
const bodyStart = source.indexOf("{", fnStart);
let depth = 0;
let bodyEnd = -1;
for (let i = bodyStart; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) {
bodyEnd = i;
break;
}
}
}
assert.ok(bodyEnd !== -1, "Could not locate closing brace of checkAndRefreshToken");
const fnBody = source.slice(fnStart, bodyEnd + 1);
// Count occurrences of updateProviderCredentials in the function body.
const allOccurrences = [...fnBody.matchAll(/updateProviderCredentials/g)];
// There must be at least 2: one inside the mutex closure, one in the
// copilot block (lines ~212-218), and optionally one for the !connectionId guard.
// The critical invariant: no bare updateProviderCredentials call appears
// OUTSIDE the mutex without a !connectionId guard.
// Locate the outer (non-mutex, non-copilot) call and verify it is inside
// an `if (!connectionId)` block.
const mutexCallEnd = fnBody.indexOf("})") + 2; // end of mutex call block
const afterMutex = fnBody.slice(mutexCallEnd);
// The first updateProviderCredentials after the mutex call (but before
// the copilot section) should be guarded by !connectionId.
const outerCallMatch = afterMutex.match(
/if\s*\(\s*!connectionId\s*\)\s*\{[\s\S]*?updateProviderCredentials/
);
assert.ok(
outerCallMatch !== null,
"The updateProviderCredentials call outside the mutex closure MUST be inside " +
"an `if (!connectionId)` guard. Without this guard, the DB write executes " +
"twice when connectionId is set: once inside the mutex (correct) and once " +
"after (race condition re-introduced)."
);
// Verify the total occurrence count is consistent (not 0 outside mutex).
assert.ok(
allOccurrences.length >= 2,
`Expected at least 2 calls to updateProviderCredentials in checkAndRefreshToken ` +
`(mutex closure + copilot block), got ${allOccurrences.length}`
);
});