Add sorting to detail breakdown tables
This commit is contained in:
@@ -3,12 +3,13 @@
|
|||||||
import { useEffect, useState, useCallback, startTransition } from "react";
|
import { useEffect, useState, useCallback, startTransition } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen } from "lucide-react";
|
import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { StatsCard } from "@/components/StatsCard";
|
import { StatsCard } from "@/components/StatsCard";
|
||||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||||
import { TrendChart } from "@/components/charts/TrendChart";
|
import { TrendChart } from "@/components/charts/TrendChart";
|
||||||
import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
|
import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
|
||||||
|
import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "@/lib/detail-sort";
|
||||||
import { useTimeRange } from "@/lib/time-range-context";
|
import { useTimeRange } from "@/lib/time-range-context";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
@@ -16,8 +17,8 @@ interface DetailData {
|
|||||||
calls: number; prompt_tokens: number; completion_tokens: number;
|
calls: number; prompt_tokens: number; completion_tokens: number;
|
||||||
cache_creation_tokens: number; cache_read_tokens: number;
|
cache_creation_tokens: number; cache_read_tokens: number;
|
||||||
total_tokens: number; quota: number; display_name?: string;
|
total_tokens: number; quota: number; display_name?: string;
|
||||||
models?: { name: string; calls: number; total_tokens: number; quota: number }[];
|
models?: DetailBreakdownItem[];
|
||||||
users?: { name: string; calls: number; total_tokens: number; quota: number }[];
|
users?: DetailBreakdownItem[];
|
||||||
channel_name?: string;
|
channel_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ export default function DetailPage() {
|
|||||||
const [data, setData] = useState<DetailData | null>(null);
|
const [data, setData] = useState<DetailData | null>(null);
|
||||||
const [trends, setTrends] = useState<{ date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number }[]>([]);
|
const [trends, setTrends] = useState<{ date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number }[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [breakdownSortKey, setBreakdownSortKey] = useState<DetailBreakdownSortKey>("total_tokens");
|
||||||
|
const [breakdownSortAsc, setBreakdownSortAsc] = useState(false);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
startTransition(() => setLoading(true));
|
startTransition(() => setLoading(true));
|
||||||
@@ -54,8 +57,28 @@ export default function DetailPage() {
|
|||||||
const title = type === "channel" ? (data?.channel_name || decodedId) : (data?.display_name || decodedId);
|
const title = type === "channel" ? (data?.channel_name || decodedId) : (data?.display_name || decodedId);
|
||||||
const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type;
|
const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type;
|
||||||
const breakdownItems = data?.models || data?.users || [];
|
const breakdownItems = data?.models || data?.users || [];
|
||||||
|
const sortedBreakdownItems = sortDetailBreakdown(breakdownItems, breakdownSortKey, breakdownSortAsc);
|
||||||
const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist");
|
const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist");
|
||||||
|
|
||||||
|
function handleBreakdownSort(key: DetailBreakdownSortKey) {
|
||||||
|
if (breakdownSortKey === key) setBreakdownSortAsc(!breakdownSortAsc);
|
||||||
|
else { setBreakdownSortKey(key); setBreakdownSortAsc(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBreakdownSortIcon = (col: DetailBreakdownSortKey) => {
|
||||||
|
if (breakdownSortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.4 }} />;
|
||||||
|
return breakdownSortAsc
|
||||||
|
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
|
||||||
|
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const breakdownColumns: { key: DetailBreakdownSortKey | null; label: string; align: "left" | "right" }[] = [
|
||||||
|
{ key: null, label: t("th.name"), align: "left" },
|
||||||
|
{ key: "calls", label: t("th.calls"), align: "right" },
|
||||||
|
{ key: "total_tokens", label: t("th.totalToken"), align: "right" },
|
||||||
|
{ key: "quota", label: t("th.cost"), align: "right" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -95,14 +118,26 @@ export default function DetailPage() {
|
|||||||
<table className="w-full text-sm mt-3">
|
<table className="w-full text-sm mt-3">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.name")}</th>
|
{breakdownColumns.map((col) => {
|
||||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.calls")}</th>
|
const key = col.key;
|
||||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.totalToken")}</th>
|
return (
|
||||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.cost")}</th>
|
<th key={col.label}
|
||||||
|
className={`px-5 py-3 text-xs font-medium uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"} ${key ? "cursor-pointer select-none transition-colors hover:opacity-80" : ""}`}
|
||||||
|
style={{ color: key && breakdownSortKey === key ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||||
|
onClick={key ? () => handleBreakdownSort(key) : undefined}
|
||||||
|
>
|
||||||
|
{key ? (
|
||||||
|
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}>
|
||||||
|
{col.label} {renderBreakdownSortIcon(key)}
|
||||||
|
</span>
|
||||||
|
) : col.label}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{breakdownItems.map((item) => (
|
{sortedBreakdownItems.map((item) => (
|
||||||
<tr key={item.name} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
<tr key={item.name} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||||
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.8 }}>{item.name}</td>
|
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.8 }}>{item.name}</td>
|
||||||
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
|
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
|
||||||
|
|||||||
23
lib/detail-sort.test.ts
Normal file
23
lib/detail-sort.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { sortDetailBreakdown } from "./detail-sort";
|
||||||
|
|
||||||
|
describe("sortDetailBreakdown", () => {
|
||||||
|
const rows = [
|
||||||
|
{ name: "beta", calls: 2, total_tokens: 100, quota: 500 },
|
||||||
|
{ name: "alpha", calls: 5, total_tokens: 100, quota: 300 },
|
||||||
|
{ name: "gamma", calls: 1, total_tokens: 300, quota: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("sorts by the selected numeric field without mutating the source rows", () => {
|
||||||
|
const sorted = sortDetailBreakdown(rows, "quota", false);
|
||||||
|
|
||||||
|
expect(sorted.map((row) => row.name)).toEqual(["beta", "alpha", "gamma"]);
|
||||||
|
expect(rows.map((row) => row.name)).toEqual(["beta", "alpha", "gamma"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses name as a stable tie-breaker", () => {
|
||||||
|
const sorted = sortDetailBreakdown(rows, "total_tokens", false);
|
||||||
|
|
||||||
|
expect(sorted.map((row) => row.name)).toEqual(["gamma", "alpha", "beta"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
lib/detail-sort.ts
Normal file
20
lib/detail-sort.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface DetailBreakdownItem {
|
||||||
|
name: string;
|
||||||
|
calls: number;
|
||||||
|
total_tokens: number;
|
||||||
|
quota: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetailBreakdownSortKey = "calls" | "total_tokens" | "quota";
|
||||||
|
|
||||||
|
export function sortDetailBreakdown(
|
||||||
|
items: DetailBreakdownItem[],
|
||||||
|
sortKey: DetailBreakdownSortKey,
|
||||||
|
sortAsc: boolean
|
||||||
|
): DetailBreakdownItem[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const diff = a[sortKey] - b[sortKey];
|
||||||
|
if (diff !== 0) return sortAsc ? diff : -diff;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user