mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
fix(dashboard): make quota providers expandable (#9025)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user