From 3a9e57f682225f902fa772c0a9bc302beba59097 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 25 Oct 2026 05:01:24 +0100 Subject: [PATCH] Fix the usage page's tables, range tabs and day bars - Both tables lay their heading and rows on one shared grid, so every figure sits under its own heading (twui's Row wrapped the cells). - The selected range reads as pressed: ink-filled in a hairline track. - Each day's bar opens a small summary card on hover or tap. - twui 0.1.7 places popovers before paint, so opening one no longer makes the layout flicker. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 4 +- package.json | 2 +- .../admin/usage/(data)/usage-table-columns.ts | 12 +++++ .../admin/usage/(partials)/usage-day-bar.tsx | 51 ++++++++++--------- .../usage/(partials)/usage-day-chart.tsx | 8 +-- .../usage/(partials)/usage-day-summary.tsx | 49 ++++++++++++++++++ .../(partials)/usage-harness-breakdown.tsx | 17 +++++-- .../usage/(partials)/usage-harness-row.tsx | 17 +++++-- .../(partials)/usage-model-head-cell.tsx | 2 +- .../usage/(partials)/usage-model-row.tsx | 23 ++++++--- .../usage/(partials)/usage-models-table.tsx | 23 ++++++--- .../usage/(partials)/usage-range-button.tsx | 39 ++++++++++++++ .../usage/(partials)/usage-range-picker.tsx | 26 ++++------ .../usage/(partials)/usage-summary-line.tsx | 20 ++++++++ 14 files changed, 221 insertions(+), 72 deletions(-) create mode 100644 src/pages/admin/usage/(data)/usage-table-columns.ts create mode 100644 src/pages/admin/usage/(partials)/usage-day-summary.tsx create mode 100644 src/pages/admin/usage/(partials)/usage-range-button.tsx create mode 100644 src/pages/admin/usage/(partials)/usage-summary-line.tsx diff --git a/bun.lock b/bun.lock index 9525f19..cb0bdce 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bunext": "^1.3.3", - "@moduletrace/twui": "^0.1.6", + "@moduletrace/twui": "^0.1.7", "@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-mention": "3.32.0", "@tiptap/extension-placeholder": "3.32.0", @@ -255,7 +255,7 @@ "@moduletrace/bunext": ["@moduletrace/bunext@1.3.3", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="], - "@moduletrace/twui": ["@moduletrace/twui@0.1.6", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.6/twui-0.1.6.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-BFNIV1itkIYXmhq1rBJA8E8MjwN9AP7MESbOnzM0yUb6xbsOFIBbmlKfUu6kTr7QCl1TWNxWTGFPVmQ2S54cDA=="], + "@moduletrace/twui": ["@moduletrace/twui@0.1.7", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.7/twui-0.1.7.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-/Y9QyhtQsoEAJO+ahlGYQaYMx3gUWS+DlSkaBVjAIY5V9OQ6i7qhhqMNcmnfOi9m3eyAze1uW00jox+RDUqLpA=="], "@next/mdx": ["@next/mdx@16.3.0", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="], diff --git a/package.json b/package.json index 7ee0c8c..54ac4b4 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bunext": "^1.3.3", - "@moduletrace/twui": "^0.1.6", + "@moduletrace/twui": "^0.1.7", "@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-mention": "3.32.0", "@tiptap/extension-placeholder": "3.32.0", diff --git a/src/pages/admin/usage/(data)/usage-table-columns.ts b/src/pages/admin/usage/(data)/usage-table-columns.ts new file mode 100644 index 0000000..0c44141 --- /dev/null +++ b/src/pages/admin/usage/(data)/usage-table-columns.ts @@ -0,0 +1,12 @@ +/** + * The column tracks of the models table, shared by its heading and every + * row, so a figure always sits under its own heading whatever its width. + * The table never gets narrower than its columns: it scrolls instead. + */ +export const UsageModelColumns = + "grid grid-cols-[minmax(160px,1fr)_48px_56px_56px_64px_64px_64px] min-w-[620px]"; + +/** + * The column tracks of the breakdown by kind of call, the same way. + */ +export const UsageHarnessColumns = "grid grid-cols-[minmax(0,1fr)_40px_72px_64px]"; diff --git a/src/pages/admin/usage/(partials)/usage-day-bar.tsx b/src/pages/admin/usage/(partials)/usage-day-bar.tsx index 38540c7..2df1b05 100644 --- a/src/pages/admin/usage/(partials)/usage-day-bar.tsx +++ b/src/pages/admin/usage/(partials)/usage-day-bar.tsx @@ -1,39 +1,44 @@ +import Popover from "@moduletrace/twui/elements/Popover"; import Stack from "@moduletrace/twui/layout/Stack"; import { twMerge } from "tailwind-merge"; -import dayjs from "dayjs"; import type { UsageOverview } from "@/src/types"; -import formatTokens from "@/src/utils/format-tokens"; import { usageBarHeight } from "../(data)/usage-bar-heights"; +import UsageDaySummary from "./usage-day-summary"; type Props = { day: UsageOverview["days"][number]; max: number; + /** + * Every day's tokens in the period, for the day's share. + */ + total_tokens: number; }; /** - * One day's bar in the chart: its tokens above nothing, the bar itself, - * and the title spells the day out with what it spent. Hovering it shows - * that title. + * One day's bar in the chart. Hovering it (or tapping it) opens a small + * card above it summing the day up. */ -export default function UsageDayBar({ day, max }: Props) { - const title = `${dayjs(day.at).format("ddd, MMM D")}: ${formatTokens({ tokens: day.total_tokens })} tokens, ${daysCalls({ calls: day.calls })}`; - +export default function UsageDayBar({ day, max, total_tokens }: Props) { return ( - + } + className="w-56 p-3 gap-2! bg-surface dark:bg-surface border-hairline dark:border-hairline rounded-default shadow-popover" > - - + + ); } - -function daysCalls({ calls }: { calls: number }) { - return `${calls} ${calls == 1 ? "call" : "calls"}`; -} diff --git a/src/pages/admin/usage/(partials)/usage-day-chart.tsx b/src/pages/admin/usage/(partials)/usage-day-chart.tsx index 3457a76..0187e5f 100644 --- a/src/pages/admin/usage/(partials)/usage-day-chart.tsx +++ b/src/pages/admin/usage/(partials)/usage-day-chart.tsx @@ -13,10 +13,12 @@ type Props = { /** * The period day by day, one bar a day, so a spike or a quiet stretch is - * read at a glance. The busiest day's total labels the top. + * read at a glance. The busiest day's total labels the top, and each bar + * opens a small summary of its day. */ export default function UsageDayChart({ days }: Props) { const max = days.reduce((highest, day) => Math.max(highest, day.total_tokens), 0); + const total_tokens = days.reduce((total, day) => total + day.total_tokens, 0); const first = days[0]; const last = days[days.length - 1]; @@ -30,9 +32,9 @@ export default function UsageDayChart({ days }: Props) { } > - + {days.map((day) => ( - + ))} {first && last ? ( diff --git a/src/pages/admin/usage/(partials)/usage-day-summary.tsx b/src/pages/admin/usage/(partials)/usage-day-summary.tsx new file mode 100644 index 0000000..06db4c0 --- /dev/null +++ b/src/pages/admin/usage/(partials)/usage-day-summary.tsx @@ -0,0 +1,49 @@ +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import dayjs from "dayjs"; +import type { UsageOverview } from "@/src/types"; +import formatTokens from "@/src/utils/format-tokens"; +import UsageSummaryLine from "./usage-summary-line"; + +type Props = { + day: UsageOverview["days"][number]; + /** + * Every day's tokens in the period, for the day's share. + */ + total_tokens: number; +}; + +/** + * What one day spent, in the card its bar opens: the day spelled out, then + * its tokens, how they split, its calls, its cost and its share of the + * period. + */ +export default function UsageDaySummary({ day, total_tokens }: Props) { + const share = total_tokens ? Math.round((day.total_tokens / total_tokens) * 100) : 0; + + const lines = [ + { label: "Tokens", value: formatTokens({ tokens: day.total_tokens }) }, + { label: "In", value: formatTokens({ tokens: day.prompt_tokens }) }, + { label: "Out", value: formatTokens({ tokens: day.completion_tokens }) }, + { label: "Model calls", value: formatTokens({ tokens: day.calls }) }, + { label: "Cost", value: day.cost ? `$${day.cost.toFixed(2)}` : "-" }, + { label: "Share of period", value: `${share}%` }, + ]; + + return ( + + + {dayjs(day.at).format("dddd, MMM D")} + + {day.calls ? ( + + {lines.map((line) => ( + + ))} + + ) : ( + No model calls. + )} + + ); +} diff --git a/src/pages/admin/usage/(partials)/usage-harness-breakdown.tsx b/src/pages/admin/usage/(partials)/usage-harness-breakdown.tsx index 3c8b3e7..7d35eaa 100644 --- a/src/pages/admin/usage/(partials)/usage-harness-breakdown.tsx +++ b/src/pages/admin/usage/(partials)/usage-harness-breakdown.tsx @@ -3,7 +3,9 @@ import Row from "@moduletrace/twui/layout/Row"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; import type { UsageOverview } from "@/src/types"; +import { twMerge } from "tailwind-merge"; import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"; +import { UsageHarnessColumns } from "../(data)/usage-table-columns"; import sumTokens from "../(functions)/sum-tokens"; import UsageHarnessRow from "./usage-harness-row"; @@ -32,17 +34,22 @@ export default function UsageHarnessBreakdown({ rows }: Props) { return ( - - + + Kind - + Share - + Tokens - + Cost diff --git a/src/pages/admin/usage/(partials)/usage-harness-row.tsx b/src/pages/admin/usage/(partials)/usage-harness-row.tsx index 77bcf99..5bbdfcf 100644 --- a/src/pages/admin/usage/(partials)/usage-harness-row.tsx +++ b/src/pages/admin/usage/(partials)/usage-harness-row.tsx @@ -1,8 +1,10 @@ import Row from "@moduletrace/twui/layout/Row"; import Span from "@moduletrace/twui/layout/Span"; +import { twMerge } from "tailwind-merge"; import type { UsageOverview } from "@/src/types"; import formatTokens from "@/src/utils/format-tokens"; import { UsageHarnessTitles } from "../(data)/usage-harness-titles"; +import { UsageHarnessColumns } from "../(data)/usage-table-columns"; type Props = { row: UsageOverview["by_harness"][number]; @@ -20,17 +22,22 @@ export default function UsageHarnessRow({ row, total_tokens }: Props) { const share = total_tokens ? Math.round((row.total_tokens / total_tokens) * 100) : 0; return ( - - + + {UsageHarnessTitles[row.harness] || row.harness} - + {share}% - + {formatTokens({ tokens: row.total_tokens })} - + {row.cost ? `$${row.cost.toFixed(2)}` : "-"} diff --git a/src/pages/admin/usage/(partials)/usage-model-head-cell.tsx b/src/pages/admin/usage/(partials)/usage-model-head-cell.tsx index 5a34d38..61c2793 100644 --- a/src/pages/admin/usage/(partials)/usage-model-head-cell.tsx +++ b/src/pages/admin/usage/(partials)/usage-model-head-cell.tsx @@ -13,7 +13,7 @@ export default function UsageModelHeadCell({ title, className }: Props) { return ( diff --git a/src/pages/admin/usage/(partials)/usage-model-row.tsx b/src/pages/admin/usage/(partials)/usage-model-row.tsx index 43ef94d..5c2bfcb 100644 --- a/src/pages/admin/usage/(partials)/usage-model-row.tsx +++ b/src/pages/admin/usage/(partials)/usage-model-row.tsx @@ -1,9 +1,11 @@ import Row from "@moduletrace/twui/layout/Row"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; +import { twMerge } from "tailwind-merge"; import type { UsageOverview } from "@/src/types"; import formatTokens from "@/src/utils/format-tokens"; import { UsageHarnessTitles } from "../(data)/usage-harness-titles"; +import { UsageModelColumns } from "../(data)/usage-table-columns"; type Props = { row: UsageOverview["models"][number]; @@ -20,8 +22,13 @@ export default function UsageModelRow({ row }: Props) { : "-"; return ( - - + + {row.model_name || row.model_slug} @@ -29,22 +36,22 @@ export default function UsageModelRow({ row }: Props) { {row.provider_name} - {UsageHarnessTitles[row.harness] || row.harness} - + {formatTokens({ tokens: row.calls })} - + {formatTokens({ tokens: row.prompt_tokens })} - + {formatTokens({ tokens: row.completion_tokens })} - + {formatTokens({ tokens: row.total_tokens })} - + {per_thousand} - + {row.cost ? `$${row.cost.toFixed(2)}` : "-"} diff --git a/src/pages/admin/usage/(partials)/usage-models-table.tsx b/src/pages/admin/usage/(partials)/usage-models-table.tsx index 20b41e3..2035b68 100644 --- a/src/pages/admin/usage/(partials)/usage-models-table.tsx +++ b/src/pages/admin/usage/(partials)/usage-models-table.tsx @@ -3,7 +3,9 @@ import Row from "@moduletrace/twui/layout/Row"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; import type { UsageOverview } from "@/src/types"; +import { twMerge } from "tailwind-merge"; import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"; +import { UsageModelColumns } from "../(data)/usage-table-columns"; import UsageModelHeadCell from "./usage-model-head-cell"; import UsageModelRow from "./usage-model-row"; @@ -34,14 +36,19 @@ export default function UsageModelsTable({ rows, days }: Props) { description={`The models this install ran in the last ${days} days, busiest first. The price per thousand tokens is read from what the provider reported.`} > - - - - - - - - + + + + + + + + {rows.map((row) => ( void; +}; + +/** + * One period of the range picker: ink-filled while it is the one showing, + * quiet otherwise. + */ +export default function UsageRangeButton({ range, selected, loading, onChange }: Props) { + return ( + + ); +} diff --git a/src/pages/admin/usage/(partials)/usage-range-picker.tsx b/src/pages/admin/usage/(partials)/usage-range-picker.tsx index 82742b9..6cd4a1f 100644 --- a/src/pages/admin/usage/(partials)/usage-range-picker.tsx +++ b/src/pages/admin/usage/(partials)/usage-range-picker.tsx @@ -1,6 +1,6 @@ -import Button from "@moduletrace/twui/layout/Button"; import Row from "@moduletrace/twui/layout/Row"; import { UsageRanges } from "../(data)/usage-ranges"; +import UsageRangeButton from "./usage-range-button"; type Props = { days: number; @@ -9,26 +9,20 @@ type Props = { }; /** - * The periods the page totals, one button each. The one showing is the - * one that reads as pressed. + * The periods the page totals, one segment each in a hairline track. The + * one showing is filled in ink, so it reads as pressed. */ export default function UsageRangePicker({ days, loading, onChange }: Props) { return ( - + {UsageRanges.map((range) => ( - + range={range} + selected={range.days == days} + loading={loading} + onChange={onChange} + /> ))} ); diff --git a/src/pages/admin/usage/(partials)/usage-summary-line.tsx b/src/pages/admin/usage/(partials)/usage-summary-line.tsx new file mode 100644 index 0000000..fe52d68 --- /dev/null +++ b/src/pages/admin/usage/(partials)/usage-summary-line.tsx @@ -0,0 +1,20 @@ +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; + +type Props = { + label: string; + value: string; +}; + +/** + * One figure of a day's summary card: what it is on the left, the figure + * on the right. + */ +export default function UsageSummaryLine({ label, value }: Props) { + return ( + + {label} + {value} + + ); +}