mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(quota+providers): card-grid layout, provider group headers, Codex race fix (#2667)
Integrated into release/v3.8.3
This commit is contained in:
committed by
GitHub
parent
f030b007ac
commit
0ead4cbb54
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
tests/unit/quota-helpers.test.ts
Normal file
132
tests/unit/quota-helpers.test.ts
Normal 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");
|
||||
});
|
||||
134
tests/unit/token-refresh-race.test.ts
Normal file
134
tests/unit/token-refresh-race.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user