mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(dashboard): make quota card ordering deterministic (#9329)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake).
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import QuotaCard from "./QuotaCard";
|
||||
import { PROVIDER_ORDER } from "./constants";
|
||||
import { compareProviderGroups } from "./utils";
|
||||
import { compareTr } from "@/shared/utils/turkishText";
|
||||
|
||||
interface Props {
|
||||
connections: any[];
|
||||
@@ -47,50 +50,63 @@ export default function QuotaCardGrid({
|
||||
}: Props) {
|
||||
if (connections.length === 0) return null;
|
||||
|
||||
// Group connections by provider, preserving the order from sortedConnections.
|
||||
// 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.
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="columns-1 2xl:columns-2 gap-6 [column-fill:_balance]">
|
||||
{[...groups.entries()].map(([provider, conns]) => (
|
||||
<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((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}
|
||||
/>
|
||||
))}
|
||||
{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((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>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
calculatePercentage,
|
||||
matchesProviderFilter,
|
||||
buildProviderOptions,
|
||||
compareQuotaConnections,
|
||||
} from "./utils";
|
||||
import Card from "@/shared/components/Card";
|
||||
import { CardSkeleton } from "@/shared/components/Loading";
|
||||
@@ -529,8 +530,12 @@ export default function ProviderLimits({
|
||||
);
|
||||
|
||||
const sortedConnections = useMemo(() => {
|
||||
return [...filteredConnections].sort(
|
||||
(a, b) => (PROVIDER_ORDER[a.provider] || 99) - (PROVIDER_ORDER[b.provider] || 99)
|
||||
return [...filteredConnections].sort((a, b) =>
|
||||
compareQuotaConnections(a, b, {
|
||||
providerOrder: PROVIDER_ORDER,
|
||||
providerLabels: PROVIDER_LABEL,
|
||||
compare: compareTr,
|
||||
})
|
||||
);
|
||||
}, [filteredConnections]);
|
||||
const visibleQuotaData = useVisibleQuotaData(sortedConnections, quotaData);
|
||||
@@ -650,9 +655,10 @@ export default function ProviderLimits({
|
||||
return true;
|
||||
});
|
||||
|
||||
// Inside each group we still want "critical first, then alert, then ok,
|
||||
// then empty; tiebreak by soonest reset". Provider order between groups
|
||||
// is enforced separately via PROVIDER_ORDER.
|
||||
// Provider rank stays the outer sort key so each group keeps its fixed
|
||||
// slot (mirrors dashboard/providers determinism); "critical first, then
|
||||
// alert, then ok, then empty; tiebreak by soonest reset" only orders
|
||||
// accounts inside their own provider group.
|
||||
const statusRank: Record<StatusKey, number> = {
|
||||
critical: 0,
|
||||
alert: 1,
|
||||
@@ -660,14 +666,21 @@ export default function ProviderLimits({
|
||||
empty: 3,
|
||||
all: 4,
|
||||
};
|
||||
return [...filtered].sort((a, b) => {
|
||||
const sa = statusRank[statusByConnection[a.id] || "empty"];
|
||||
const sb = statusRank[statusByConnection[b.id] || "empty"];
|
||||
if (sa !== sb) return sa - sb;
|
||||
const ra = getSoonestResetMs(visibleQuotaData[a.id]?.quotas);
|
||||
const rb = getSoonestResetMs(visibleQuotaData[b.id]?.quotas);
|
||||
return ra - rb;
|
||||
});
|
||||
return [...filtered].sort((a, b) =>
|
||||
compareQuotaConnections(a, b, {
|
||||
providerOrder: PROVIDER_ORDER,
|
||||
providerLabels: PROVIDER_LABEL,
|
||||
compare: compareTr,
|
||||
accountCompare: (x, y) => {
|
||||
const sx = statusRank[statusByConnection[x.id] || "empty"];
|
||||
const sy = statusRank[statusByConnection[y.id] || "empty"];
|
||||
if (sx !== sy) return sx - sy;
|
||||
const rx = getSoonestResetMs(visibleQuotaData[x.id]?.quotas);
|
||||
const ry = getSoonestResetMs(visibleQuotaData[y.id]?.quotas);
|
||||
return rx - ry;
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [
|
||||
sortedConnections,
|
||||
tierByConnection,
|
||||
|
||||
@@ -620,3 +620,110 @@ export function buildProviderOptions(
|
||||
}
|
||||
return Array.from(seen).sort(compare);
|
||||
}
|
||||
|
||||
// --- Deterministic quota-card ordering -------------------------------------
|
||||
// Mirrors the dashboard/providers rule (`providerPageUtils.ts::
|
||||
// sortProviderEntriesByName`): every level of ordering must end in a stable,
|
||||
// data-independent tiebreak so cards never re-flow between refreshes.
|
||||
//
|
||||
// Before this, `visibleConnections` globally sorted ALL connections by
|
||||
// status then soonest reset, and QuotaCardGrid grouped by first-appearance —
|
||||
// so each provider group's position was decided by whichever of its accounts
|
||||
// happened to sort first (status/reset change every refresh → groups
|
||||
// shuffled). Provider rank is now a sort key again, so a group's position is
|
||||
// fixed by PROVIDER_ORDER and account status/reset only orders accounts
|
||||
// inside their own group.
|
||||
|
||||
export interface QuotaOrderConnection {
|
||||
id?: unknown;
|
||||
provider?: unknown;
|
||||
name?: unknown;
|
||||
email?: unknown;
|
||||
displayName?: unknown;
|
||||
}
|
||||
|
||||
/** Label/name key: providers-page `getProviderSortLabel` — case-insensitive display name. */
|
||||
function quotaConnLabel(conn: QuotaOrderConnection): string {
|
||||
const name = typeof conn.name === "string" ? conn.name : "";
|
||||
const provider = typeof conn.provider === "string" ? conn.provider : "";
|
||||
return (name || provider).toLowerCase();
|
||||
}
|
||||
|
||||
/** Technical tiebreak key: providers-page `providerId.localeCompare(...)` — ASCII on purpose. */
|
||||
function quotaConnTiebreak(conn: QuotaOrderConnection): string {
|
||||
const email = typeof conn.email === "string" ? conn.email : "";
|
||||
const id = typeof conn.id === "string" ? conn.id : String(conn.id ?? "");
|
||||
return email || id;
|
||||
}
|
||||
|
||||
function providerRank(provider: unknown, providerOrder: Record<string, number>): number {
|
||||
const key = typeof provider === "string" ? provider : "";
|
||||
return providerOrder[key] ?? 99;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order connections for the quota card grid. Levels (first non-zero wins):
|
||||
* 1. `PROVIDER_ORDER` rank — keeps each provider group glued to its fixed slot.
|
||||
* 2. Provider label (locale-aware, case-insensitive) — orders unranked providers.
|
||||
* 3. Provider key ASCII — deterministic tiebreak between aliased/equal labels.
|
||||
* 4. `accountCompare` (optional) — in-group intent (critical-first, soonest reset).
|
||||
* 5. Account label, then email/id ASCII — so equal-status accounts never shuffle.
|
||||
*/
|
||||
export function compareQuotaConnections<T extends QuotaOrderConnection>(
|
||||
a: T,
|
||||
b: T,
|
||||
opts: {
|
||||
providerOrder: Record<string, number>;
|
||||
providerLabels?: Record<string, string>;
|
||||
accountCompare?: (a: T, b: T) => number;
|
||||
compare?: (a: string, b: string) => number;
|
||||
}
|
||||
): number {
|
||||
const cmp = opts.compare ?? ((x: string, y: string) => x.localeCompare(y));
|
||||
const labels = opts.providerLabels ?? {};
|
||||
|
||||
const ra = providerRank(a.provider, opts.providerOrder);
|
||||
const rb = providerRank(b.provider, opts.providerOrder);
|
||||
if (ra !== rb) return ra - rb;
|
||||
|
||||
const pa = typeof a.provider === "string" ? a.provider : "";
|
||||
const pb = typeof b.provider === "string" ? b.provider : "";
|
||||
const providerLabelCmp = cmp(labels[pa] ?? pa, labels[pb] ?? pb);
|
||||
if (providerLabelCmp !== 0) return providerLabelCmp;
|
||||
if (pa !== pb) return pa < pb ? -1 : 1;
|
||||
|
||||
if (opts.accountCompare) {
|
||||
const acc = opts.accountCompare(a, b);
|
||||
if (acc !== 0) return acc;
|
||||
}
|
||||
|
||||
const accountLabelCmp = cmp(quotaConnLabel(a), quotaConnLabel(b));
|
||||
if (accountLabelCmp !== 0) return accountLabelCmp;
|
||||
const ta = quotaConnTiebreak(a);
|
||||
const tb = quotaConnTiebreak(b);
|
||||
return ta < tb ? -1 : ta > tb ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order provider group keys for rendering. Same provider-level rule as
|
||||
* `compareQuotaConnections` (rank → label → key), used by QuotaCardGrid to
|
||||
* place group headers deterministically.
|
||||
*/
|
||||
export function compareProviderGroups(
|
||||
a: string,
|
||||
b: string,
|
||||
opts: {
|
||||
providerOrder: Record<string, number>;
|
||||
providerLabels?: Record<string, string>;
|
||||
compare?: (a: string, b: string) => number;
|
||||
}
|
||||
): number {
|
||||
const cmp = opts.compare ?? ((x: string, y: string) => x.localeCompare(y));
|
||||
const labels = opts.providerLabels ?? {};
|
||||
const ra = providerRank(a, opts.providerOrder);
|
||||
const rb = providerRank(b, opts.providerOrder);
|
||||
if (ra !== rb) return ra - rb;
|
||||
const labelCmp = cmp(labels[a] ?? a, labels[b] ?? b);
|
||||
if (labelCmp !== 0) return labelCmp;
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
136
tests/unit/quota-deterministic-order.test.ts
Normal file
136
tests/unit/quota-deterministic-order.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Guards the deterministic quota-card ordering fix. Before this, the quota
|
||||
// dashboard globally sorted ALL connections by status then soonest reset and
|
||||
// grouped by first-appearance — so each provider group's position was decided
|
||||
// by whichever account happened to sort first, and groups shuffled on every
|
||||
// refresh. These tests pin the providers-page-style rule: provider rank →
|
||||
// provider label → provider key fixes each group's slot, and status/reset only
|
||||
// orders accounts inside their own group with a deterministic name/email/id
|
||||
// tiebreak.
|
||||
|
||||
const utils =
|
||||
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx");
|
||||
const constants =
|
||||
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/constants.ts");
|
||||
|
||||
const { compareQuotaConnections, compareProviderGroups } = utils;
|
||||
const { PROVIDER_ORDER, PROVIDER_LABEL } = constants;
|
||||
|
||||
const OPTS = { providerOrder: PROVIDER_ORDER, providerLabels: PROVIDER_LABEL };
|
||||
|
||||
function ids(conns) {
|
||||
return conns.map((c) => c.id);
|
||||
}
|
||||
|
||||
test("quota-order: provider rank dominates account status (group slot is fixed)", () => {
|
||||
// antigravity (rank 1) ok account vs codex (rank 4) critical account.
|
||||
// Pre-fix, the critical codex account floated above antigravity and dragged
|
||||
// the codex group to the top. Now antigravity's rank wins.
|
||||
const okAntigravity = { id: "a", provider: "antigravity", name: "Ag" };
|
||||
const criticalCodex = { id: "c", provider: "codex", name: "Cx" };
|
||||
// accountCompare that would put codex first if provider rank were ignored:
|
||||
const criticalFirst = (x, y) =>
|
||||
(x.provider === "codex" ? 0 : 1) - (y.provider === "codex" ? 0 : 1);
|
||||
|
||||
const sorted = [criticalCodex, okAntigravity].sort((a, b) =>
|
||||
compareQuotaConnections(a, b, { ...OPTS, accountCompare: criticalFirst })
|
||||
);
|
||||
assert.deepEqual(ids(sorted), ["a", "c"], "provider rank outranks account status");
|
||||
});
|
||||
|
||||
test("quota-order: accountCompare orders accounts within the same provider group", () => {
|
||||
const a = { id: "a", provider: "codex", name: "Acct A" };
|
||||
const b = { id: "b", provider: "codex", name: "Acct B" };
|
||||
// b is "critical" (rank 0), a is "ok" (rank 2) via the injected comparator.
|
||||
const statusOf = { a: 2, b: 0 };
|
||||
const criticalFirst = (x, y) => statusOf[x.id] - statusOf[y.id];
|
||||
|
||||
const sorted = [a, b].sort((x, y) =>
|
||||
compareQuotaConnections(x, y, { ...OPTS, accountCompare: criticalFirst })
|
||||
);
|
||||
assert.deepEqual(ids(sorted), ["b", "a"], "critical account first inside its group");
|
||||
});
|
||||
|
||||
test("quota-order: deterministic name tiebreak makes equal-status accounts stable", () => {
|
||||
const make = () => [
|
||||
{ id: "1", provider: "codex", name: "Zeta" },
|
||||
{ id: "2", provider: "codex", name: "Alpha" },
|
||||
{ id: "3", provider: "codex", name: "Mid" },
|
||||
];
|
||||
const noStatus = () => 0;
|
||||
const first = [...make()].sort((x, y) =>
|
||||
compareQuotaConnections(x, y, { ...OPTS, accountCompare: noStatus })
|
||||
);
|
||||
const second = [...make()]
|
||||
.reverse()
|
||||
.sort((x, y) => compareQuotaConnections(x, y, { ...OPTS, accountCompare: noStatus }));
|
||||
assert.deepEqual(ids(first), ["2", "3", "1"], "name alphabetical");
|
||||
assert.deepEqual(ids(first), ids(second), "input order does not change output");
|
||||
});
|
||||
|
||||
test("quota-order: unranked providers fall back to label alphabetical, after ranked", () => {
|
||||
// github is ranked 3; the two fake providers are unranked (→ 99). Two
|
||||
// unranked providers order among themselves by label (here label falls back
|
||||
// to the provider key since neither is in PROVIDER_LABEL).
|
||||
const conns = [
|
||||
{ id: "1", provider: "zzz-unranked", name: "X" },
|
||||
{ id: "2", provider: "aaa-unranked", name: "Y" },
|
||||
{ id: "3", provider: "github", name: "Z" },
|
||||
];
|
||||
const sorted = [...conns].sort((a, b) => compareQuotaConnections(a, b, OPTS));
|
||||
assert.deepEqual(
|
||||
ids(sorted),
|
||||
["3", "2", "1"],
|
||||
"ranked first; unranked by label (labels fall back to provider key here)"
|
||||
);
|
||||
});
|
||||
|
||||
test("quota-order: group keys order by rank then label then key", () => {
|
||||
const keys = ["codex", "antigravity", "some-new-provider", "github"];
|
||||
const sorted = [...keys].sort((a, b) => compareProviderGroups(a, b, OPTS));
|
||||
assert.deepEqual(
|
||||
sorted,
|
||||
["antigravity", "github", "codex", "some-new-provider"],
|
||||
"PROVIDER_ORDER rank 1,3,4 then unranked provider last"
|
||||
);
|
||||
});
|
||||
|
||||
test("quota-order: aliased providers with equal rank break tie by provider key", () => {
|
||||
// xai-oauth and xao are both rank 16 in PROVIDER_ORDER. Deterministic key
|
||||
// tiebreak (ASCII) must decide, not insertion order.
|
||||
const a = ["xai-oauth", "xao"].sort((x, y) => compareProviderGroups(x, y, OPTS));
|
||||
const b = ["xao", "xai-oauth"].sort((x, y) => compareProviderGroups(x, y, OPTS));
|
||||
assert.deepEqual(a, b, "stable regardless of input order");
|
||||
assert.deepEqual(a[0], "xai-oauth", "ASCII: 'xai-oauth' < 'xao'");
|
||||
});
|
||||
|
||||
test("quota-order: full grid end-to-end keeps group slots fixed while ordering accounts inside", () => {
|
||||
// Simulate the real pipeline: flat list across providers, mixed statuses.
|
||||
const conns = [
|
||||
{ id: "ag1", provider: "antigravity", name: "Ag One" },
|
||||
{ id: "cx-ok", provider: "codex", name: "Codex Ok" },
|
||||
{ id: "gh1", provider: "github", name: "Gh One" },
|
||||
{ id: "cx-crit", provider: "codex", name: "Codex Crit" },
|
||||
{ id: "ag2", provider: "antigravity", name: "Ag Two" },
|
||||
];
|
||||
// codex-crit is critical, everything else ok.
|
||||
const statusOf = { "cx-crit": 0 };
|
||||
const criticalFirst = (x, y) => (statusOf[x.id] ?? 2) - (statusOf[y.id] ?? 2);
|
||||
|
||||
const sorted = [...conns].sort((a, b) =>
|
||||
compareQuotaConnections(a, b, { ...OPTS, accountCompare: criticalFirst })
|
||||
);
|
||||
|
||||
// Provider group slots follow rank: antigravity(1) → github(3) → codex(4).
|
||||
// The critical codex account does NOT pull the codex group to the front.
|
||||
assert.deepEqual(
|
||||
sorted.map((c) => c.provider),
|
||||
["antigravity", "antigravity", "github", "codex", "codex"]
|
||||
);
|
||||
// Inside codex, critical first.
|
||||
assert.deepEqual(ids(sorted).slice(3), ["cx-crit", "cx-ok"]);
|
||||
// Inside antigravity, name alphabetical.
|
||||
assert.deepEqual(ids(sorted).slice(0, 2), ["ag1", "ag2"]);
|
||||
});
|
||||
Reference in New Issue
Block a user