fix(dashboard): restore Usage Model Breakdown column sorting (#8769) (#8802)

Extract ModelTable from the charts god-file and drive header clicks through a
single sort state object plus a pure sorter so column toggles reliably reorder
rows. Compute missing share pct from summary totals when the analytics API
omits it.
This commit is contained in:
AmirHossein Rezaei
2026-07-28 05:07:55 +03:30
committed by GitHub
parent 4d66dd113f
commit 0e14d66a71
5 changed files with 339 additions and 137 deletions

View File

@@ -0,0 +1,168 @@
"use client";
/**
* Model Breakdown table for Usage analytics (#8769).
* Extracted from charts.tsx so sort logic can be fixed/tested without
* growing the frozen charts god-file.
*/
import { useCallback, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "../Card";
import { getModelColor } from "@/shared/constants/colors";
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
import {
nextModelBreakdownSort,
sortModelBreakdownRows,
withModelBreakdownShare,
type ModelBreakdownRow,
type ModelBreakdownSortField,
type ModelBreakdownSortOrder,
} from "./modelBreakdownSort";
interface ModelTableProps {
byModel?: ModelBreakdownRow[] | null;
summary?: { totalTokens?: number | null } | null;
}
type Column = {
field: ModelBreakdownSortField;
labelKey: string;
align: "left" | "right";
};
const COLUMNS: Column[] = [
{ field: "model", labelKey: "chartModel", align: "left" },
{ field: "requests", labelKey: "chartRequests", align: "right" },
{ field: "promptTokens", labelKey: "chartInput", align: "right" },
{ field: "completionTokens", labelKey: "chartOutput", align: "right" },
{ field: "totalTokens", labelKey: "chartTotal", align: "right" },
{ field: "cost", labelKey: "chartCost", align: "right" },
];
function SortIndicator({ active, sortOrder }: { active: boolean; sortOrder: string }) {
if (!active) {
return (
<span className="material-symbols-outlined text-[12px] opacity-0 group-hover:opacity-30">
unfold_more
</span>
);
}
return (
<span className="material-symbols-outlined text-[12px] text-primary">
{sortOrder === "asc" ? "expand_less" : "expand_more"}
</span>
);
}
export function ModelTable({ byModel, summary }: ModelTableProps) {
const t = useTranslations("analytics");
const [sort, setSort] = useState<{
by: ModelBreakdownSortField;
order: ModelBreakdownSortOrder;
}>({ by: "totalTokens", order: "desc" });
const toggleSort = useCallback((field: ModelBreakdownSortField) => {
setSort((prev) => {
const next = nextModelBreakdownSort(prev.by, prev.order, field);
return { by: next.sortBy, order: next.sortOrder };
});
}, []);
const rowsWithShare = useMemo(
() => withModelBreakdownShare(byModel, summary?.totalTokens),
[byModel, summary?.totalTokens]
);
const sorted = useMemo(
() => sortModelBreakdownRows(rowsWithShare, sort.by, sort.order),
[rowsWithShare, sort.by, sort.order]
);
return (
<Card className="overflow-hidden">
<div className="p-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
{t("chartModelBreakdown")}
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]">
<tr>
{COLUMNS.map((col) => (
<th
key={col.field}
className={`px-4 py-2.5 ${col.align === "right" ? "text-right" : "text-left"}`}
>
<button
type="button"
className={`inline-flex items-center gap-1 cursor-pointer group hover:text-text-main ${
col.align === "right" ? "justify-end w-full" : "justify-start"
}`}
onClick={() => toggleSort(col.field)}
>
<span>{t(col.labelKey)}</span>
<SortIndicator active={sort.by === col.field} sortOrder={sort.order} />
</button>
</th>
))}
<th className="px-4 py-2.5 text-right w-36">{t("chartShare")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sorted.map((m, i) => (
<tr
key={String(m.model ?? i)}
className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(i) }}
/>
<span className="font-medium">{m.model}</span>
</div>
</td>
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
{fmtFull(m.requests)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-primary">
{fmt(m.promptTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
{fmt(m.completionTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono font-semibold">
{fmt(m.totalTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-amber-500">
{fmtCost(m.cost)}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center gap-2 justify-end">
<div className="w-16 h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${m.pct ?? 0}%`,
backgroundColor: getModelColor(i),
}}
/>
</div>
<span className="text-xs font-mono text-text-muted w-10 text-right">
{m.pct ?? 0}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
export default ModelTable;

View File

@@ -595,143 +595,9 @@ export function WeeklySquares7d({ activityMap }) {
);
}
// ── ModelTable ──────────────────────────────────────────────────────────────
// ── ModelTable (extracted — see ModelTable.tsx / #8769) ───────────────────
export function ModelTable({ byModel, summary }) {
const t = useTranslations("analytics");
const [sortBy, setSortBy] = useState("totalTokens");
const [sortOrder, setSortOrder] = useState("desc");
const toggleSort = useCallback(
(field) => {
if (sortBy === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(field);
setSortOrder("desc");
}
},
[sortBy]
);
const sorted = useMemo(() => {
const arr = [...(byModel || [])];
arr.sort((a, b) => {
const va = a[sortBy] ?? 0;
const vb = b[sortBy] ?? 0;
if (typeof va === "string")
return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
return sortOrder === "asc" ? va - vb : vb - va;
});
return arr;
}, [byModel, sortBy, sortOrder]);
return (
<Card className="overflow-hidden">
<div className="p-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
{t("chartModelBreakdown")}
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]">
<tr>
<th
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => toggleSort("model")}
>
{t("chartModel")}{" "}
<SortIndicator active={sortBy === "model"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("requests")}
>
{t("chartRequests")}{" "}
<SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("promptTokens")}
>
{t("chartInput")}{" "}
<SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("completionTokens")}
>
{t("chartOutput")}{" "}
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("totalTokens")}
>
{t("chartTotal")}{" "}
<SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("cost")}
>
{t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
</th>
<th className="px-4 py-2.5 text-right w-36">{t("chartShare")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sorted.map((m, i) => (
<tr
key={`${m.model}-${i}`}
className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(i) }}
/>
<span className="font-medium">{m.model}</span>
</div>
</td>
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
{fmtFull(m.requests)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-primary">
{fmt(m.promptTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
{fmt(m.completionTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono font-semibold">
{fmt(m.totalTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-amber-500">
{fmtCost(m.cost)}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center gap-2 justify-end">
<div className="w-16 h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{ width: `${m.pct}%`, backgroundColor: getModelColor(i) }}
/>
</div>
<span className="text-xs font-mono text-text-muted w-10 text-right">
{m.pct}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
export { ModelTable } from "./ModelTable";
function getServiceTierIcon(serviceTier) {
if (serviceTier === "priority") return "bolt";

View File

@@ -16,11 +16,11 @@ export {
ApiKeyTable,
MostActiveDay7d,
WeeklySquares7d,
ModelTable,
UsageDetail,
ProviderTable,
ServiceTierBreakdown,
} from "./charts";
export { ModelTable } from "./ModelTable";
export { AccountDonut, ApiKeyDonut, ProviderCostDonut } from "./rechartsDonuts";
export { DailyTrendChart, ModelOverTimeChart } from "./rechartsUsageCharts";

View File

@@ -0,0 +1,79 @@
/**
* Pure sort helper for the Usage → Model Breakdown table (#8769).
* Kept out of charts.tsx so the frozen god-file does not grow and so
* sort behavior is unit-testable without mounting React.
*/
export type ModelBreakdownSortField =
| "model"
| "requests"
| "promptTokens"
| "completionTokens"
| "totalTokens"
| "cost";
export type ModelBreakdownSortOrder = "asc" | "desc";
export interface ModelBreakdownRow {
model?: string | null;
requests?: number | null;
promptTokens?: number | null;
completionTokens?: number | null;
totalTokens?: number | null;
cost?: number | null;
pct?: number | string | null;
[key: string]: unknown;
}
const STRING_FIELDS = new Set<ModelBreakdownSortField>(["model"]);
export function sortModelBreakdownRows(
rows: readonly ModelBreakdownRow[] | null | undefined,
sortBy: ModelBreakdownSortField,
sortOrder: ModelBreakdownSortOrder
): ModelBreakdownRow[] {
const arr = [...(rows || [])];
arr.sort((a, b) => {
const rawA = a[sortBy];
const rawB = b[sortBy];
if (STRING_FIELDS.has(sortBy) || typeof rawA === "string" || typeof rawB === "string") {
const sa = String(rawA ?? "");
const sb = String(rawB ?? "");
const cmp = sa.localeCompare(sb, undefined, { sensitivity: "base", numeric: true });
return sortOrder === "asc" ? cmp : -cmp;
}
const va = Number(rawA ?? 0);
const vb = Number(rawB ?? 0);
const safeA = Number.isFinite(va) ? va : 0;
const safeB = Number.isFinite(vb) ? vb : 0;
return sortOrder === "asc" ? safeA - safeB : safeB - safeA;
});
return arr;
}
export function nextModelBreakdownSort(
currentBy: ModelBreakdownSortField,
currentOrder: ModelBreakdownSortOrder,
field: ModelBreakdownSortField
): { sortBy: ModelBreakdownSortField; sortOrder: ModelBreakdownSortOrder } {
if (currentBy === field) {
return { sortBy: field, sortOrder: currentOrder === "asc" ? "desc" : "asc" };
}
return { sortBy: field, sortOrder: "desc" };
}
/** Attach share % from summary totals when the API omitted `pct` (#8769 display). */
export function withModelBreakdownShare(
rows: readonly ModelBreakdownRow[] | null | undefined,
totalTokens: number | null | undefined
): ModelBreakdownRow[] {
const total = Number(totalTokens ?? 0);
return (rows || []).map((row) => {
if (row.pct !== undefined && row.pct !== null && row.pct !== "") return { ...row };
const tokens = Number(row.totalTokens ?? 0);
const pct = total > 0 ? ((tokens / total) * 100).toFixed(1) : "0";
return { ...row, pct };
});
}

View File

@@ -0,0 +1,89 @@
/**
* Regression #8769: Usage → Model Breakdown column-header sort must reorder rows.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
nextModelBreakdownSort,
sortModelBreakdownRows,
withModelBreakdownShare,
} from "../../src/shared/components/analytics/modelBreakdownSort.ts";
const ROWS = [
{
model: "alpha",
requests: 10,
promptTokens: 100,
completionTokens: 50,
totalTokens: 150,
cost: 0.5,
},
{
model: "bravo",
requests: 30,
promptTokens: 10,
completionTokens: 90,
totalTokens: 100,
cost: 2.0,
},
{
model: "charlie",
requests: 5,
promptTokens: 200,
completionTokens: 10,
totalTokens: 210,
cost: 0.1,
},
];
test("#8769: sort by requests desc puts bravo first", () => {
const sorted = sortModelBreakdownRows(ROWS, "requests", "desc");
assert.deepEqual(
sorted.map((r) => r.model),
["bravo", "alpha", "charlie"]
);
});
test("#8769: sort by totalTokens asc puts bravo first", () => {
const sorted = sortModelBreakdownRows(ROWS, "totalTokens", "asc");
assert.deepEqual(
sorted.map((r) => r.model),
["bravo", "alpha", "charlie"]
);
});
test("#8769: sort by model asc is alphabetical", () => {
const sorted = sortModelBreakdownRows(ROWS, "model", "asc");
assert.deepEqual(
sorted.map((r) => r.model),
["alpha", "bravo", "charlie"]
);
});
test("#8769: sort by cost desc puts bravo first", () => {
const sorted = sortModelBreakdownRows(ROWS, "cost", "desc");
assert.equal(sorted[0].model, "bravo");
assert.equal(sorted[2].model, "charlie");
});
test("#8769: nextModelBreakdownSort toggles order on same field", () => {
assert.deepEqual(nextModelBreakdownSort("requests", "desc", "requests"), {
sortBy: "requests",
sortOrder: "asc",
});
assert.deepEqual(nextModelBreakdownSort("requests", "asc", "cost"), {
sortBy: "cost",
sortOrder: "desc",
});
});
test("#8769: withModelBreakdownShare fills missing pct from summary total", () => {
const withPct = withModelBreakdownShare(ROWS, 460);
assert.equal(withPct[0].pct, ((150 / 460) * 100).toFixed(1));
assert.equal(withPct[1].pct, ((100 / 460) * 100).toFixed(1));
});
test("#8769: sort is stable for empty/null input", () => {
assert.deepEqual(sortModelBreakdownRows(null, "requests", "desc"), []);
assert.deepEqual(sortModelBreakdownRows(undefined, "model", "asc"), []);
});