mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
4d66dd113f
commit
0e14d66a71
168
src/shared/components/analytics/ModelTable.tsx
Normal file
168
src/shared/components/analytics/ModelTable.tsx
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
79
src/shared/components/analytics/modelBreakdownSort.ts
Normal file
79
src/shared/components/analytics/modelBreakdownSort.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
89
tests/unit/model-breakdown-sort-8769.test.ts
Normal file
89
tests/unit/model-breakdown-sort-8769.test.ts
Normal 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"), []);
|
||||
});
|
||||
Reference in New Issue
Block a user