fix(dashboard): make quota providers expandable (#9025)

This commit is contained in:
JK TAN
2026-08-11 20:54:40 +08:00
committed by GitHub
parent a99c795a67
commit 16b67f5f68
2 changed files with 274 additions and 55 deletions

View File

@@ -1,9 +1,10 @@
"use client";
import type { ReactNode } from "react";
import QuotaCard from "./QuotaCard";
import { useState } from "react";
import { PROVIDER_ORDER } from "./constants";
import { compareProviderGroups } from "./utils";
import QuotaCard from "./QuotaCard";
import { worstStatus, type CardStatus, compareProviderGroups } from "./utils";
import { compareTr } from "@/shared/utils/turkishText";
interface Props {
@@ -29,6 +30,191 @@ interface Props {
compact?: boolean;
}
const STATUS_RANK: Record<CardStatus, number> = {
critical: 0,
alert: 1,
ok: 2,
empty: 3,
};
function getSoonestResetMs(quotas: any[] | undefined): number {
if (!Array.isArray(quotas) || quotas.length === 0) return Number.POSITIVE_INFINITY;
const now = Date.now();
let soonest = Number.POSITIVE_INFINITY;
for (const quota of quotas) {
if (!quota?.resetAt) continue;
const ts = new Date(quota.resetAt).getTime();
if (Number.isFinite(ts) && ts > now && ts < soonest) soonest = ts;
}
return soonest;
}
function getRemainingPercentage(quota: any): number {
if (quota?.unlimited) return 100;
if (typeof quota?.remainingPercentage === "number") return quota.remainingPercentage;
if (typeof quota?.total === "number" && quota.total > 0) {
const used = typeof quota.used === "number" ? quota.used : 0;
return Math.max(0, Math.min(100, Math.round(((quota.total - used) / quota.total) * 100)));
}
return 100;
}
function getLowestRemainingPercentage(quotas: any[] | undefined): number {
if (!Array.isArray(quotas) || quotas.length === 0) return 100;
let lowest = 100;
for (const quota of quotas) {
lowest = Math.min(lowest, getRemainingPercentage(quota));
}
return lowest;
}
function hasUsableQuota(quotas: any[] | undefined): boolean {
if (!Array.isArray(quotas) || quotas.length === 0) return true;
return quotas.some((quota) => quota?.unlimited || getRemainingPercentage(quota) > 0);
}
function getConnectionLabel(connection: any): string {
return String(
connection.name || connection.displayName || connection.email || connection.id || ""
);
}
export function sortProviderConnectionsByPriority(
connections: any[],
quotaData: Record<string, any>
) {
return [...connections].sort((a, b) => {
const aActive = a.isActive ?? true;
const bActive = b.isActive ?? true;
if (aActive !== bActive) return aActive ? -1 : 1;
const aQuotas = quotaData[a.id]?.quotas;
const bQuotas = quotaData[b.id]?.quotas;
const aUsable = hasUsableQuota(aQuotas);
const bUsable = hasUsableQuota(bQuotas);
if (aUsable !== bUsable) return aUsable ? -1 : 1;
const statusDiff = STATUS_RANK[worstStatus(aQuotas)] - STATUS_RANK[worstStatus(bQuotas)];
if (statusDiff !== 0) return statusDiff;
const resetDiff = getSoonestResetMs(aQuotas) - getSoonestResetMs(bQuotas);
if (resetDiff !== 0) return resetDiff;
const remainingDiff =
getLowestRemainingPercentage(aQuotas) - getLowestRemainingPercentage(bQuotas);
if (remainingDiff !== 0) return remainingDiff;
return getConnectionLabel(a).localeCompare(getConnectionLabel(b));
});
}
function buildProviderGroups(
connections: any[],
quotaData: Record<string, any>,
providerLabels: Record<string, string>
) {
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 [...groups.entries()]
.map(([provider, conns]) => ({
provider,
connections: sortProviderConnectionsByPriority(conns, quotaData),
}))
.sort((a, b) =>
compareProviderGroups(a.provider, b.provider, {
providerOrder: PROVIDER_ORDER,
providerLabels,
compare: compareTr,
})
);
}
interface ProviderQuotaSectionProps extends Omit<Props, "connections"> {
provider: string;
connections: any[];
defaultOpen?: boolean;
}
function ProviderQuotaSection({
provider,
connections,
defaultOpen = true,
quotaData,
loading,
errors,
lastRefreshedAt,
emailsVisible,
providerLabels,
onRefresh,
onOpenCutoff,
onOpenResetCredits,
onToggleActive,
togglingActiveId,
redeemingResetCreditId = null,
loadingResetCreditsId = null,
quotaVisibility,
onHideQuota,
onShowQuota,
}: ProviderQuotaSectionProps) {
const [open, setOpen] = useState(defaultOpen);
const activeCount = connections.filter((conn) => conn.isActive ?? true).length;
const providerLabel = providerLabels[provider] || provider;
return (
<details
open={open}
onToggle={(event) => setOpen((event.target as HTMLDetailsElement).open)}
className="rounded-lg border border-border bg-surface overflow-hidden"
>
<summary className="flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none hover:bg-white/[0.03] [&::-webkit-details-marker]:hidden">
<span className="material-symbols-outlined text-[16px] text-text-muted">
{open ? "expand_less" : "expand_more"}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-text-main leading-5 truncate">
{providerLabel}
</h3>
<p className="text-[11px] text-text-muted tabular-nums">
{activeCount} active / {connections.length} account
{connections.length !== 1 ? "s" : ""}
</p>
</div>
</summary>
<div className="px-3 pb-3">
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
{connections.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)}
onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)}
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
togglingActive={togglingActiveId === conn.id}
redeemingResetCredit={redeemingResetCreditId === conn.id}
loadingResetCredits={loadingResetCreditsId === conn.id}
quotaVisibility={quotaVisibility}
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
/>
))}
</div>
</div>
</details>
);
}
export default function QuotaCardGrid({
connections,
quotaData,
@@ -75,52 +261,47 @@ export default function QuotaCardGrid({
/>
);
// Default (non-compact) layout: group connections by provider (preserving
// in-group order), then order the groups deterministically: PROVIDER_ORDER
// rank → label (locale-aware) → key. Without this the group order followed
// first-appearance in the status/reset-sorted list, so groups shuffled
// whenever quota refreshed.
if (!compact) {
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);
}
const orderedProviders = [...groups.keys()].sort((a, b) =>
compareProviderGroups(a, b, {
providerOrder: PROVIDER_ORDER,
providerLabels,
compare: compareTr,
})
);
// Compact mode: flat 3-column card grid, across all connections.
if (compact) {
return (
<div className="columns-1 2xl:columns-2 gap-6 [column-fill:_balance]">
{orderedProviders.map((provider) => {
const conns = groups.get(provider)!;
return (
<div key={provider} className="flex flex-col gap-3 break-inside-avoid mb-6">
<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-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
{conns.map(renderCard)}
</div>
</div>
);
})}
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
{connections.map(renderCard)}
</div>
);
}
// Compact mode: flat 3-column card grid, across all connections.
// Default layout: expandable provider sections. Provider groups are ordered
// deterministically (PROVIDER_ORDER rank → label → key) so the group order
// never shuffles between quota refreshes, and connections are
// priority-sorted within each provider.
const groups = buildProviderGroups(connections, quotaData, providerLabels);
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
{connections.map(renderCard)}
<div className="space-y-4">
{groups.map(({ provider, connections: conns }) => (
<ProviderQuotaSection
key={provider}
provider={provider}
connections={conns}
quotaData={quotaData}
loading={loading}
errors={errors}
lastRefreshedAt={lastRefreshedAt}
emailsVisible={emailsVisible}
providerLabels={providerLabels}
onRefresh={onRefresh}
onOpenCutoff={onOpenCutoff}
onOpenResetCredits={onOpenResetCredits}
onToggleActive={onToggleActive}
togglingActiveId={togglingActiveId}
quotaVisibility={quotaVisibility}
onHideQuota={onHideQuota}
onShowQuota={onShowQuota}
redeemingResetCreditId={redeemingResetCreditId}
loadingResetCreditsId={loadingResetCreditsId}
defaultOpen
/>
))}
</div>
);
}

View File

@@ -1,17 +1,16 @@
// #3520 — Provider Quota page should use horizontal whitespace better.
// Provider Quota page should be easy to scan provider-by-provider.
//
// QuotaCardGrid previously stacked provider groups vertically via a single
// `flex flex-col` container and sized card columns from fixed viewport
// breakpoints. This regression guard asserts the shipped JSX structure and
// grouping logic directly:
// QuotaCardGrid used to flow provider groups into CSS columns. That made
// high-account operators scan left/right across unrelated providers and made
// Codex/Claude quota priority hard to follow. This regression guard asserts
// the shipped JSX structure and grouping logic directly:
// 1. Grouping still produces one header per distinct provider with the
// correct account count ("N account(s)").
// 2. Each provider group's cards auto-fit into as many 280px columns as that
// group's actual width supports, while a card can shrink to the group's
// width when its container is narrower than 280px.
// 3. Provider groups themselves flow into multiple columns on wide screens
// (`columns-*`) instead of an unconditional vertical `flex flex-col`
// stack.
// 3. Provider groups themselves are one-column expandable sections, not a
// two-column masonry flow.
//
// Note: QuotaCardGrid's sibling QuotaCard pulls in next/image + provider-icon
// resolution that only works inside the real Next.js runtime, so this file
@@ -98,11 +97,50 @@ function extractDivClassNames(sourcePath: string): string[] {
return classNames;
}
test("QuotaCardGrid (#3520) — outer container flows groups into multiple columns, not a single vertical stack", () => {
const [outerClassName] = extractDivClassNames(COMPONENT_PATH);
assert.ok(outerClassName, "expected the component to render an outer <div className=...>");
assert.match(outerClassName, /\bcolumns-/);
assert.notEqual(outerClassName, "flex flex-col gap-6");
test("QuotaCardGrid — outer provider sections stay in one vertical reading order", () => {
const classNames = extractDivClassNames(COMPONENT_PATH);
const outerClassName = classNames.find((className) => /\bspace-y-\d+\b/.test(className));
assert.ok(
outerClassName,
"expected the component to render a vertical provider-section container"
);
assert.doesNotMatch(
outerClassName!,
/\bcolumns-/,
"provider sections should not flow into two CSS columns"
);
assert.match(
outerClassName,
/(?:^|\s)(?:space-y-\d+|flex)(?:\s|$)/,
"provider sections should render as one vertical list"
);
});
test("QuotaCardGrid — provider groups are expandable details sections", () => {
const sourceText = fs.readFileSync(COMPONENT_PATH, "utf8");
assert.match(sourceText, /<details\b/, "expected each provider group to use <details>");
assert.match(sourceText, /<summary\b/, "expected each provider group to expose a summary row");
assert.match(sourceText, /\bdefaultOpen\b/, "expected provider sections to default open");
});
test("QuotaCardGrid — provider sections and accounts use quota monitoring priority", () => {
const sourceText = fs.readFileSync(COMPONENT_PATH, "utf8");
assert.match(
sourceText,
/\bPROVIDER_ORDER\b/,
"expected provider sections to use provider order"
);
assert.match(
sourceText,
/\bsortProviderConnectionsByPriority\b/,
"expected accounts inside a provider to use a dedicated priority sort"
);
assert.match(sourceText, /\bisActive\b/, "expected active accounts to sort before inactive ones");
assert.match(
sourceText,
/\bgetSoonestResetMs\b/,
"expected account priority to consider the next quota reset"
);
});
test("QuotaCardGrid (#3520) — cards follow actual group width with a narrow-container fallback", () => {