@linxiraos/pi-stats 1.1.27 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +10 -7
  2. package/build.ts +4 -0
  3. package/dist/client/favicon.ico +0 -0
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +1 -0
  6. package/dist/client/index.js +96 -96
  7. package/dist/types/aggregator.d.ts +4 -1
  8. package/dist/types/client/api.d.ts +5 -1
  9. package/dist/types/client/app/nav.d.ts +5 -2
  10. package/dist/types/client/data/formatters.d.ts +5 -2
  11. package/dist/types/client/data/live.d.ts +16 -11
  12. package/dist/types/client/data/query-store.d.ts +39 -0
  13. package/dist/types/client/data/query.d.ts +10 -5
  14. package/dist/types/client/data/range.d.ts +4 -3
  15. package/dist/types/client/i18n/format.d.ts +9 -0
  16. package/dist/types/client/i18n/index.d.ts +18 -0
  17. package/dist/types/client/i18n/messages/charts.d.ts +8 -0
  18. package/dist/types/client/i18n/messages/common.d.ts +153 -0
  19. package/dist/types/client/i18n/messages/costs.d.ts +92 -0
  20. package/dist/types/client/i18n/messages/en.d.ts +1255 -0
  21. package/dist/types/client/i18n/messages/errors.d.ts +78 -0
  22. package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
  23. package/dist/types/client/i18n/messages/gain.d.ts +54 -0
  24. package/dist/types/client/i18n/messages/models.d.ts +94 -0
  25. package/dist/types/client/i18n/messages/nav.d.ts +46 -0
  26. package/dist/types/client/i18n/messages/overview.d.ts +80 -0
  27. package/dist/types/client/i18n/messages/projects.d.ts +84 -0
  28. package/dist/types/client/i18n/messages/providers.d.ts +142 -0
  29. package/dist/types/client/i18n/messages/requests.d.ts +72 -0
  30. package/dist/types/client/i18n/messages/tools.d.ts +119 -0
  31. package/dist/types/client/i18n/messages/traces.d.ts +144 -0
  32. package/dist/types/client/i18n/messages/ui.d.ts +44 -0
  33. package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
  34. package/dist/types/client/i18n/types.d.ts +15 -0
  35. package/dist/types/client/traces/Minimap.d.ts +2 -0
  36. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  37. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  38. package/dist/types/db.d.ts +14 -21
  39. package/dist/types/embedded-client.d.ts +5 -0
  40. package/dist/types/frustration.d.ts +18 -9
  41. package/dist/types/live.d.ts +5 -3
  42. package/dist/types/locale.d.ts +16 -0
  43. package/dist/types/parser.d.ts +0 -17
  44. package/dist/types/rollup.d.ts +9 -1
  45. package/dist/types/server.d.ts +6 -0
  46. package/dist/types/shared-types.d.ts +7 -0
  47. package/dist/types/trace.d.ts +18 -6
  48. package/dist/types/types.d.ts +12 -0
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +4 -4
  51. package/src/aggregator.ts +28 -11
  52. package/src/client/api.ts +40 -5
  53. package/src/client/app/LiveChip.tsx +14 -13
  54. package/src/client/app/Shell.tsx +19 -13
  55. package/src/client/app/ThemeToggle.tsx +4 -3
  56. package/src/client/app/nav.ts +19 -16
  57. package/src/client/data/formatters.ts +73 -13
  58. package/src/client/data/live.tsx +85 -45
  59. package/src/client/data/query-store.ts +108 -0
  60. package/src/client/data/query.ts +47 -56
  61. package/src/client/data/range.ts +34 -14
  62. package/src/client/data/series.ts +7 -1
  63. package/src/client/favicon.ico +0 -0
  64. package/src/client/i18n/format.ts +28 -0
  65. package/src/client/i18n/index.ts +53 -0
  66. package/src/client/i18n/messages/charts.ts +8 -0
  67. package/src/client/i18n/messages/common.ts +157 -0
  68. package/src/client/i18n/messages/costs.ts +104 -0
  69. package/src/client/i18n/messages/en.ts +40 -0
  70. package/src/client/i18n/messages/errors.ts +80 -0
  71. package/src/client/i18n/messages/frustration.ts +198 -0
  72. package/src/client/i18n/messages/gain.ts +54 -0
  73. package/src/client/i18n/messages/models.ts +94 -0
  74. package/src/client/i18n/messages/nav.ts +46 -0
  75. package/src/client/i18n/messages/overview.ts +90 -0
  76. package/src/client/i18n/messages/projects.ts +84 -0
  77. package/src/client/i18n/messages/providers.ts +165 -0
  78. package/src/client/i18n/messages/requests.ts +73 -0
  79. package/src/client/i18n/messages/tools.ts +120 -0
  80. package/src/client/i18n/messages/traces.ts +160 -0
  81. package/src/client/i18n/messages/ui.ts +46 -0
  82. package/src/client/i18n/messages/zh-CN.ts +43 -0
  83. package/src/client/i18n/types.ts +19 -0
  84. package/src/client/index.tsx +4 -0
  85. package/src/client/routes/CostsRoute.tsx +61 -48
  86. package/src/client/routes/ErrorsRoute.tsx +71 -47
  87. package/src/client/routes/FrustrationRoute.tsx +131 -116
  88. package/src/client/routes/GainRoute.tsx +42 -30
  89. package/src/client/routes/ModelsRoute.tsx +87 -71
  90. package/src/client/routes/OverviewRoute.tsx +107 -62
  91. package/src/client/routes/ProjectsRoute.tsx +71 -45
  92. package/src/client/routes/ProvidersRoute.tsx +149 -113
  93. package/src/client/routes/RequestsRoute.tsx +68 -45
  94. package/src/client/routes/ToolsRoute.tsx +105 -92
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +4 -2
  97. package/src/client/traces/AggregatesPanel.tsx +19 -8
  98. package/src/client/traces/Minimap.tsx +65 -27
  99. package/src/client/traces/SpanDrawer.tsx +46 -21
  100. package/src/client/traces/SummaryStrip.tsx +27 -11
  101. package/src/client/traces/TimelineCanvas.tsx +245 -116
  102. package/src/client/traces/TraceView.tsx +51 -36
  103. package/src/client/traces/TranscriptList.tsx +20 -11
  104. package/src/client/traces/traces.css +26 -0
  105. package/src/client/ui/JsonBlock.tsx +3 -2
  106. package/src/client/ui/Modal.tsx +3 -2
  107. package/src/client/ui/RequestDrawer.tsx +33 -30
  108. package/src/client/ui/SearchInput.tsx +2 -1
  109. package/src/client/ui/States.tsx +3 -2
  110. package/src/client/ui/Table.tsx +7 -3
  111. package/src/db.ts +150 -135
  112. package/src/embedded-client.ts +9 -0
  113. package/src/frustration.ts +9 -30
  114. package/src/live.ts +36 -11
  115. package/src/locale.ts +29 -0
  116. package/src/parser.ts +37 -72
  117. package/src/port-conflict.ts +1 -1
  118. package/src/prompts/judge/frustration.json +19 -0
  119. package/src/rollup.ts +41 -20
  120. package/src/server.ts +67 -27
  121. package/src/shared-types.ts +7 -0
  122. package/src/trace.ts +54 -40
  123. package/src/types.ts +12 -0
  124. package/src/user-metrics.ts +220 -6
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Traces copy. Keys are slugs (`trace.<name>`); the English string is the
3
+ * source of truth and `zh-CN` overrides it.
4
+ */
5
+ export const traces = {
6
+ en: {
7
+ // ── session list (TracesRoute) ──────────────────────────────────────
8
+ "trace.title": "Traces",
9
+ "trace.description": "Recent sessions with subagent activity folded in. Open one to inspect its timeline.",
10
+ "trace.sessions": "Sessions",
11
+ "trace.session": "Session",
12
+ "trace.most-recent": "{count} most recent",
13
+ "trace.most-recent-filtered": "{shown} of {total} most recent",
14
+ "trace.filter-placeholder": "Filter by title, project, model…",
15
+ "trace.empty-title": "No sessions found",
16
+ "trace.empty-hint": "Run a sync to index recent activity.",
17
+ "trace.no-matching-title": "No matching sessions",
18
+ "trace.no-matching-hint": "Try a different filter.",
19
+ // ── span categories ─────────────────────────────────────────────────
20
+ "trace.tool": "Tool",
21
+ "trace.tools": "Tools",
22
+ "trace.agent": "Agent",
23
+ "trace.agents": "Agents",
24
+ "trace.background": "Background",
25
+ "trace.bg": "Bg",
26
+ // ── trace viewer toolbar / headings (TraceView) ─────────────────────
27
+ "trace.refresh": "Refresh trace",
28
+ "trace.timeline": "Timeline",
29
+ "trace.timeline-hint":
30
+ "W/S zoom · A/D pan · drag pan · wheel zoom · 0 fit · F focus selection · dbl-click focus · Esc deselect",
31
+ "trace.expand-all": "Expand all",
32
+ "trace.collapse-all": "Collapse all",
33
+ "trace.axis-mode": "Axis mode",
34
+ "trace.mode-time-title": "Real wall-clock time",
35
+ "trace.mode-turns-title": "Equal width per user turn",
36
+ "trace.mode-calls-title": "Equal width per model/tool call boundary",
37
+ "trace.turns": "Turns",
38
+ "trace.calls": "Calls",
39
+ "trace.compress-idle": "Compress idle",
40
+ "trace.search-placeholder": "Search spans…",
41
+ "trace.previous-match": "Previous match",
42
+ "trace.next-match": "Next match",
43
+ "trace.transcript": "Transcript",
44
+ "trace.transcript-description": "Every span and marker in order; click to inspect",
45
+ "trace.no-matching-events": "No matching events",
46
+ // ── flamegraph canvas / minimap ─────────────────────────────────────
47
+ "trace.canvas-aria": "Trace timeline. W and S zoom, A and D pan, 0 fits all, F focuses the selection.",
48
+ "trace.minimap-aria": "Timeline overview brush",
49
+ "trace.expand-track": "Expand {track}",
50
+ "trace.collapse-track": "Collapse {track}",
51
+ "trace.unterminated": "unterminated",
52
+ "trace.tok": "tok",
53
+ "trace.ttft": "TTFT",
54
+ // ── summary strip ───────────────────────────────────────────────────
55
+ "trace.wall-time": "Wall time",
56
+ "trace.model-time": "Model time",
57
+ "trace.tool-time": "Tool time",
58
+ "trace.idle": "Idle",
59
+ "trace.tool-calls-n": "{count} tool calls",
60
+ "trace.of-wall": "{percent} of wall",
61
+ // ── tool aggregates ─────────────────────────────────────────────────
62
+ "trace.tool-aggregates": "Tool aggregates",
63
+ "trace.tool-aggregates-desc": "{count} tools, by total time",
64
+ "trace.errors": "Errors",
65
+ "trace.failed-n": "{count} failed",
66
+ "trace.avg": "Avg",
67
+ "trace.max": "Max",
68
+ // ── span drawer ─────────────────────────────────────────────────────
69
+ "trace.start": "Start",
70
+ "trace.track": "Track",
71
+ "trace.io-cached": "Input / output / cached",
72
+ "trace.stop-reason": "Stop reason",
73
+ "trace.copy-json": "Copy raw JSON",
74
+ "trace.error-message": "Error message",
75
+ "trace.task": "Task",
76
+ "trace.open-child-track": "Open child track",
77
+ "trace.args": "Args",
78
+ "trace.thinking": "Thinking",
79
+ "trace.text": "Text",
80
+ "trace.raw-entry": "Raw entry",
81
+ "trace.load-failed": "Failed to load entry: {message}",
82
+ },
83
+ "zh-CN": {
84
+ // ── session list (TracesRoute) ──────────────────────────────────────
85
+ "trace.title": "调用链",
86
+ "trace.description": "最近的会话,已合并子代理活动。打开其中一个即可查看其时间线。",
87
+ "trace.sessions": "会话",
88
+ "trace.session": "会话",
89
+ "trace.most-recent": "最近 {count} 条",
90
+ "trace.most-recent-filtered": "最近 {total} 条中的 {shown} 条",
91
+ "trace.filter-placeholder": "按标题、项目或模型筛选…",
92
+ "trace.empty-title": "未找到会话",
93
+ "trace.empty-hint": "运行一次同步以索引最近的活动。",
94
+ "trace.no-matching-title": "没有匹配的会话",
95
+ "trace.no-matching-hint": "请尝试其他筛选条件。",
96
+ // ── span categories ─────────────────────────────────────────────────
97
+ "trace.tool": "工具",
98
+ "trace.tools": "工具",
99
+ "trace.agent": "智能体",
100
+ "trace.agents": "智能体",
101
+ "trace.background": "后台",
102
+ "trace.bg": "背景",
103
+ // ── trace viewer toolbar / headings (TraceView) ─────────────────────
104
+ "trace.refresh": "刷新调用链",
105
+ "trace.timeline": "时间线",
106
+ "trace.timeline-hint":
107
+ "W/S 缩放 · A/D 平移 · 拖拽平移 · 滚轮缩放 · 0 适应全部 · F 聚焦所选 · 双击聚焦 · Esc 取消选择",
108
+ "trace.expand-all": "全部展开",
109
+ "trace.collapse-all": "全部折叠",
110
+ "trace.axis-mode": "坐标轴模式",
111
+ "trace.mode-time-title": "真实墙钟时间",
112
+ "trace.mode-turns-title": "每个用户轮次等宽",
113
+ "trace.mode-calls-title": "每个模型/工具调用边界等宽",
114
+ "trace.turns": "轮次",
115
+ "trace.calls": "调用次数",
116
+ "trace.compress-idle": "压缩空闲时间",
117
+ "trace.search-placeholder": "搜索跨度…",
118
+ "trace.previous-match": "上一个匹配",
119
+ "trace.next-match": "下一个匹配",
120
+ "trace.transcript": "对话记录",
121
+ "trace.transcript-description": "按顺序列出所有跨度和标记;点击可查看详情",
122
+ "trace.no-matching-events": "没有匹配的事件",
123
+ // ── flamegraph canvas / minimap ─────────────────────────────────────
124
+ "trace.canvas-aria": "调用链时间线。W 和 S 缩放,A 和 D 平移,0 适应全部,F 聚焦所选。",
125
+ "trace.minimap-aria": "时间线概览刷选",
126
+ "trace.expand-track": "展开 {track}",
127
+ "trace.collapse-track": "折叠 {track}",
128
+ "trace.unterminated": "未结束",
129
+ "trace.tok": "Token",
130
+ "trace.ttft": "首 Token 延迟",
131
+ // ── summary strip ───────────────────────────────────────────────────
132
+ "trace.wall-time": "墙钟时间",
133
+ "trace.model-time": "模型耗时",
134
+ "trace.tool-time": "工具耗时",
135
+ "trace.idle": "空闲",
136
+ "trace.tool-calls-n": "{count} 次工具调用",
137
+ "trace.of-wall": "占墙钟时间 {percent}",
138
+ // ── tool aggregates ─────────────────────────────────────────────────
139
+ "trace.tool-aggregates": "工具汇总",
140
+ "trace.tool-aggregates-desc": "{count} 个工具,按总耗时排序",
141
+ "trace.errors": "错误",
142
+ "trace.failed-n": "{count} 个失败",
143
+ "trace.avg": "平均",
144
+ "trace.max": "最大",
145
+ // ── span drawer ─────────────────────────────────────────────────────
146
+ "trace.start": "开始",
147
+ "trace.track": "轨道",
148
+ "trace.io-cached": "输入 / 输出 / 缓存读取",
149
+ "trace.stop-reason": "停止原因",
150
+ "trace.copy-json": "复制原始 JSON",
151
+ "trace.error-message": "错误信息",
152
+ "trace.task": "任务",
153
+ "trace.open-child-track": "打开子轨道",
154
+ "trace.args": "参数",
155
+ "trace.thinking": "思考过程",
156
+ "trace.text": "文本",
157
+ "trace.raw-entry": "原始条目",
158
+ "trace.load-failed": "加载条目失败:{message}",
159
+ },
160
+ } as const;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Ui copy. Keys are slugs (`ui.<name>`); the English string is the
3
+ * source of truth and `zh-CN` overrides it.
4
+ */
5
+ export const ui = {
6
+ en: {
7
+ // ── table / states ──────────────────────────────────────────────────
8
+ "ui.table-shown-of": "{shown} of {total}",
9
+ "ui.show-fewer": "Show fewer",
10
+ "ui.retry": "Retry",
11
+ "ui.delta-new": "new",
12
+ "ui.json": "JSON",
13
+ // ── request drawer ──────────────────────────────────────────────────
14
+ "ui.trace": "Trace",
15
+ "ui.open-trace": "Open this request's session in the trace view",
16
+ "ui.status-ok": "OK",
17
+ "ui.section-timing": "Timing",
18
+ "ui.time-to-first-token": "Time to first token",
19
+ "ui.uncached-input": "Uncached input",
20
+ "ui.section-cost": "API-equivalent cost",
21
+ "ui.section-identity": "Identity",
22
+ "ui.stop-reason": "Stop reason",
23
+ "ui.api": "API",
24
+ "ui.session-file": "Session file",
25
+ "ui.stats-row": "Stats row",
26
+ },
27
+ "zh-CN": {
28
+ "ui.table-shown-of": "已显示 {shown} / 共 {total}",
29
+ "ui.show-fewer": "收起",
30
+ "ui.retry": "重试",
31
+ "ui.delta-new": "新增",
32
+ "ui.json": "JSON 数据",
33
+ "ui.trace": "调用链",
34
+ "ui.open-trace": "在调用链视图中打开该请求的会话",
35
+ "ui.status-ok": "正常",
36
+ "ui.section-timing": "时间",
37
+ "ui.time-to-first-token": "首 Token 延迟",
38
+ "ui.uncached-input": "未缓存输入",
39
+ "ui.section-cost": "API 等价费用",
40
+ "ui.section-identity": "标识",
41
+ "ui.stop-reason": "停止原因",
42
+ "ui.api": "接口",
43
+ "ui.session-file": "会话文件",
44
+ "ui.stats-row": "统计行",
45
+ },
46
+ } as const;
@@ -0,0 +1,43 @@
1
+ import type { MessageKey } from "./en";
2
+ import { nav } from "./nav";
3
+ import { common } from "./common";
4
+ import { overview } from "./overview";
5
+ import { models } from "./models";
6
+ import { providers } from "./providers";
7
+ import { costs } from "./costs";
8
+ import { requests } from "./requests";
9
+ import { errors } from "./errors";
10
+ import { traces } from "./traces";
11
+ import { tools } from "./tools";
12
+ import { frustration } from "./frustration";
13
+ import { projects } from "./projects";
14
+ import { gain } from "./gain";
15
+ import { ui } from "./ui";
16
+ import { charts } from "./charts";
17
+
18
+ /**
19
+ * Simplified Chinese overrides. Incomplete on purpose in source: the coverage
20
+ * test (`packages/stats/test/i18n-coverage.test.ts`) fails on any missing key,
21
+ * which keeps a half-translated page out of a release without breaking builds
22
+ * mid-flight.
23
+ */
24
+ export const zhCNMessages: Partial<Record<MessageKey, string>> = {
25
+ ...nav["zh-CN"],
26
+ ...common["zh-CN"],
27
+ ...overview["zh-CN"],
28
+ ...models["zh-CN"],
29
+ ...providers["zh-CN"],
30
+ ...costs["zh-CN"],
31
+ ...requests["zh-CN"],
32
+ ...errors["zh-CN"],
33
+ ...traces["zh-CN"],
34
+ ...tools["zh-CN"],
35
+ ...frustration["zh-CN"],
36
+ ...projects["zh-CN"],
37
+ ...gain["zh-CN"],
38
+ ...ui["zh-CN"],
39
+ ...charts["zh-CN"],
40
+ };
41
+
42
+ /** The Simplified Chinese locale pack. */
43
+ export const zhCNLocale = { id: "zh-CN", messages: zhCNMessages } as const;
@@ -0,0 +1,19 @@
1
+ export type { StatsLocale } from "../../locale";
2
+
3
+ import type { StatsLocale } from "../../locale";
4
+
5
+ /** Values a message may interpolate through `{name}` placeholders. */
6
+ export type TranslationParams = Record<string, string | number>;
7
+
8
+ /** A locale pack: the messages one locale overrides, keyed by slug. */
9
+ export interface LocalePack {
10
+ readonly id: StatsLocale;
11
+ readonly messages: Readonly<Record<string, string>>;
12
+ }
13
+
14
+ /**
15
+ * Messages for every locale, keyed first by locale id. `undefined` is part of
16
+ * the value type because a locale may override only some keys (English is the
17
+ * fallback for the rest).
18
+ */
19
+ export type MessagesByLocale = Readonly<Record<StatsLocale, Readonly<Record<string, string | undefined>>>>;
@@ -2,6 +2,10 @@ import "./styles.css";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import App from "./App";
4
4
 
5
+ // The locale resolves when `./i18n` is first imported (the server stamps the
6
+ // host CLI's language into the page before this module runs), so the app is
7
+ // already rendering in the right language by the time React mounts.
8
+
5
9
  const root = document.getElementById("root");
6
10
  if (!root) throw new Error("missing #root");
7
11
  createRoot(root).render(<App />);
@@ -1,6 +1,7 @@
1
1
  import "./costs.css";
2
2
  import { useMemo, useState } from "react";
3
3
  import { getCostDashboardStats } from "../api";
4
+ import { t } from "../i18n";
4
5
  import { Chart, type ChartSeries, Legend, ShareBar, useHiddenSeries } from "../charts";
5
6
  import { buildModelColorLookup, modelKey, OTHER_COLOR, SERIES_COLORS } from "../data/colors";
6
7
  import { formatCost, formatEstimatedCost, formatInteger, formatPercent } from "../data/formatters";
@@ -35,8 +36,8 @@ export interface CostsRouteProps {
35
36
  type SplitMode = "model" | "component";
36
37
 
37
38
  const SPLIT_OPTIONS = [
38
- { value: "model" as const, label: "By model" },
39
- { value: "component" as const, label: "By component" },
39
+ { value: "model" as const, label: t("costs.by-model") },
40
+ { value: "component" as const, label: t("costs.by-component") },
40
41
  ];
41
42
 
42
43
  /** Models stacked individually in the daily chart; the rest fold into "Other". */
@@ -46,10 +47,10 @@ const MODEL_LIMIT = 6;
46
47
  const DAY_MS = 86_400_000;
47
48
 
48
49
  const COMPONENTS = [
49
- { key: "costInput", label: "Input", color: "#5b8cff" },
50
- { key: "costOutput", label: "Output", color: "var(--chart-secondary)" },
51
- { key: "costCacheRead", label: "Cache read", color: "var(--chart-primary)" },
52
- { key: "costCacheWrite", label: "Cache write", color: "#f5b54a" },
50
+ { key: "costInput", label: t("common.input"), color: "#5b8cff" },
51
+ { key: "costOutput", label: t("common.output"), color: "var(--chart-secondary)" },
52
+ { key: "costCacheRead", label: t("common.cache-read"), color: "var(--chart-primary)" },
53
+ { key: "costCacheWrite", label: t("common.cache-write"), color: "#f5b54a" },
53
54
  ] as const satisfies readonly { key: keyof CostComponents; label: string; color: string }[];
54
55
 
55
56
  const UTC_DAY = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
@@ -62,20 +63,19 @@ const UTC_DAY_LONG = new Intl.DateTimeFormat(undefined, {
62
63
  });
63
64
 
64
65
  export function CostsRoute({ active, range }: CostsRouteProps) {
65
- const query = useQuery(["costs", range], () => getCostDashboardStats(range), { enabled: active });
66
+ const query = useQuery(["costs", range], ({ signal }) => getCostDashboardStats(range, signal), { enabled: active });
66
67
  const meta = rangeMeta(range);
67
68
  const [split, setSplit] = useState<SplitMode>("model");
68
69
  const [hidden, toggleSeries] = useHiddenSeries();
69
70
  const view = useMemo(() => (query.data ? buildCostsView(query.data, range) : null), [query.data, range]);
71
+ // Stable columns: a fresh array would make Table re-sort on every render.
72
+ const columns = useMemo(() => (view ? buildCostColumns(view) : []), [view]);
70
73
  const isEmpty = (data: CostDashboardStats) => data.costSeries.length === 0;
71
- const empty = <EmptyState title="No usage in this range" hint="Pick a wider range in the top bar." />;
74
+ const empty = <EmptyState title={t("costs.no-usage-in-range")} hint={t("costs.empty-hint")} />;
72
75
 
73
76
  return (
74
77
  <div className="page">
75
- <PageHeader
76
- title="Costs"
77
- description={`What ${meta.windowLabel} of usage would cost at public API rates. Subscription usage without a public price is counted as unpriced, not free.`}
78
- />
78
+ <PageHeader title={t("costs.title")} description={t("costs.description", { window: t(meta.windowKey) })} />
79
79
 
80
80
  <QueryView
81
81
  query={query}
@@ -89,17 +89,15 @@ export function CostsRoute({ active, range }: CostsRouteProps) {
89
89
  <div className="grid grid-main-side">
90
90
  <Card
91
91
  index={1}
92
- title="Daily estimate"
93
- description={
94
- split === "model" ? "Per UTC day, stacked by model" : "Per UTC day, stacked by billing component"
95
- }
92
+ title={t("costs.daily-estimate")}
93
+ description={split === "model" ? t("costs.daily-by-model") : t("costs.daily-by-component")}
96
94
  actions={
97
95
  <Segmented
98
96
  size="sm"
99
97
  options={SPLIT_OPTIONS}
100
98
  value={split}
101
99
  onChange={setSplit}
102
- aria-label="Split by"
100
+ aria-label={t("costs.split-by")}
103
101
  />
104
102
  }
105
103
  stale={query.stale}
@@ -119,11 +117,11 @@ export function CostsRoute({ active, range }: CostsRouteProps) {
119
117
  height={280}
120
118
  format={v => formatCost(v, Number.isInteger(v) ? 0 : 2)}
121
119
  formatTooltip={v => formatCost(v)}
122
- emptyLabel="No priced usage in this range"
120
+ emptyLabel={t("costs.no-priced-usage")}
123
121
  tooltipExtra={i =>
124
122
  view.unpricedPerDay[i] > 0 ? (
125
123
  <div className="chart-tooltip-row chart-tooltip-total">
126
- <span className="chart-tooltip-label">Unpriced requests</span>
124
+ <span className="chart-tooltip-label">{t("costs.unpriced-requests")}</span>
127
125
  <span className="chart-tooltip-value">
128
126
  {formatInteger(view.unpricedPerDay[i])}
129
127
  </span>
@@ -147,7 +145,12 @@ export function CostsRoute({ active, range }: CostsRouteProps) {
147
145
  </QueryView>
148
146
  </Card>
149
147
 
150
- <Card index={2} title="Where it went" description="Estimate by billing component" stale={query.stale}>
148
+ <Card
149
+ index={2}
150
+ title={t("costs.where-it-went")}
151
+ description={t("costs.estimate-by-component")}
152
+ stale={query.stale}
153
+ >
151
154
  <QueryView query={query} skeleton={<ChartSkeleton height={280} />} isEmpty={isEmpty} empty={empty}>
152
155
  {() => view && <ComponentBreakdown summary={view.summary} />}
153
156
  </QueryView>
@@ -156,8 +159,8 @@ export function CostsRoute({ active, range }: CostsRouteProps) {
156
159
 
157
160
  <Card
158
161
  index={3}
159
- title="By model"
160
- description="Estimate per model with its input, output and cache split"
162
+ title={t("costs.by-model")}
163
+ description={t("costs.by-model-description")}
161
164
  stale={query.stale}
162
165
  flush
163
166
  >
@@ -167,7 +170,7 @@ export function CostsRoute({ active, range }: CostsRouteProps) {
167
170
  <Table
168
171
  rows={view.summary.models}
169
172
  rowKey={row => row.key}
170
- columns={buildCostColumns(view)}
173
+ columns={columns}
171
174
  initialSort={{ key: "cost", dir: "desc" }}
172
175
  limit={20}
173
176
  />
@@ -245,36 +248,45 @@ function CostStats({ view, stale }: { view: CostsView; stale: boolean }) {
245
248
  <div data-stale={stale}>
246
249
  <StatGrid min={180}>
247
250
  <Stat
248
- label="API-equivalent estimate"
249
- title="What this usage would cost at public API rates"
251
+ label={t("costs.api-equivalent-estimate")}
252
+ title={t("costs.api-estimate-title")}
250
253
  value={formatEstimatedCost(summary.totalCost, summary.unpricedRequests)}
251
- hint={`${formatInteger(summary.requests)} requests`}
254
+ hint={t("costs.requests-n", { count: formatInteger(summary.requests) })}
252
255
  spark={view.dailyTotals}
253
256
  sparkColor="var(--chart-secondary)"
254
257
  />
255
258
  <Stat
256
- label="Average per day"
257
- title="Estimate ÷ days with any usage"
259
+ label={t("costs.average-per-day")}
260
+ title={t("costs.average-per-day-title")}
258
261
  value={formatEstimatedCost(summary.avgDailyCost, summary.unpricedRequests)}
259
- hint={`over ${formatInteger(summary.activeDays)} active day${summary.activeDays === 1 ? "" : "s"}`}
262
+ hint={t(summary.activeDays === 1 ? "costs.active-days-one" : "costs.active-days-n", {
263
+ count: formatInteger(summary.activeDays),
264
+ })}
260
265
  />
261
266
  <Stat
262
- label="Top model"
267
+ label={t("costs.top-model")}
263
268
  title={top ? `${top.model} (${top.provider})` : undefined}
264
269
  value={top ? top.model : "–"}
265
- hint={top ? `${formatCost(top.cost)} · ${formatPercent(top.share)} of estimate` : "Nothing priced yet"}
270
+ hint={
271
+ top
272
+ ? t("costs.top-model-hint", {
273
+ cost: formatCost(top.cost),
274
+ share: formatPercent(top.share),
275
+ })
276
+ : t("costs.nothing-priced-yet")
277
+ }
266
278
  />
267
279
  <Stat
268
- label="Per priced request"
269
- title="Estimate ÷ requests that have a public price"
280
+ label={t("costs.per-priced-request")}
281
+ title={t("costs.per-priced-request-title")}
270
282
  value={pricedRequests > 0 ? formatUnitCost(summary.totalCost / pricedRequests) : "–"}
271
- hint={`${formatInteger(pricedRequests)} priced`}
283
+ hint={t("costs.priced-n", { count: formatInteger(pricedRequests) })}
272
284
  />
273
285
  <Stat
274
- label="Unpriced requests"
275
- title="Subscription usage with no public-equivalent price; excluded from the estimate"
286
+ label={t("costs.unpriced-requests")}
287
+ title={t("costs.unpriced-title")}
276
288
  value={formatInteger(summary.unpricedRequests)}
277
- hint={summary.unpricedRequests > 0 ? "excluded from the estimate" : "all usage priced"}
289
+ hint={summary.unpricedRequests > 0 ? t("costs.unpriced-hint") : t("costs.all-priced")}
278
290
  />
279
291
  </StatGrid>
280
292
  </div>
@@ -302,15 +314,16 @@ function ComponentBreakdown({ summary }: { summary: CostSummaryView }) {
302
314
  </div>
303
315
  ))}
304
316
  <div className="costs-component-row costs-component-total">
305
- <span>Total</span>
317
+ <span>{t("common.total")}</span>
306
318
  <span className="num">{formatEstimatedCost(summary.totalCost, summary.unpricedRequests)}</span>
307
319
  <span className="costs-component-share" />
308
320
  </div>
309
321
  </div>
310
322
  {summary.unpricedRequests > 0 && (
311
323
  <p className="micro dim">
312
- {formatInteger(summary.unpricedRequests)} unpriced subscription request
313
- {summary.unpricedRequests === 1 ? " is" : "s are"} not included.
324
+ {t(summary.unpricedRequests === 1 ? "costs.unpriced-note-one" : "costs.unpriced-note-n", {
325
+ count: formatInteger(summary.unpricedRequests),
326
+ })}
314
327
  </p>
315
328
  )}
316
329
  </div>
@@ -328,7 +341,7 @@ function buildCostColumns(view: CostsView): Column<CostModelRow>[] {
328
341
  return [
329
342
  {
330
343
  key: "model",
331
- header: "Model",
344
+ header: t("common.model"),
332
345
  sort: row => row.model,
333
346
  render: row => (
334
347
  <LabelCell
@@ -340,15 +353,15 @@ function buildCostColumns(view: CostsView): Column<CostModelRow>[] {
340
353
  },
341
354
  {
342
355
  key: "requests",
343
- header: "Requests",
356
+ header: t("common.requests"),
344
357
  align: "right",
345
358
  sort: row => row.requests,
346
359
  render: row => <span className="num">{formatInteger(row.requests)}</span>,
347
360
  },
348
361
  {
349
362
  key: "cost",
350
- header: "Estimate",
351
- title: "API-equivalent estimate at public rates",
363
+ header: t("costs.estimate"),
364
+ title: t("costs.estimate-title"),
352
365
  align: "right",
353
366
  sort: row => row.cost,
354
367
  render: row => (
@@ -362,14 +375,14 @@ function buildCostColumns(view: CostsView): Column<CostModelRow>[] {
362
375
  },
363
376
  {
364
377
  key: "share",
365
- header: "Share",
378
+ header: t("common.share"),
366
379
  align: "right",
367
380
  sort: row => row.share,
368
381
  render: row => <span className="num muted">{row.cost > 0 ? formatPercent(row.share) : "–"}</span>,
369
382
  },
370
383
  {
371
384
  key: "split",
372
- header: "Split",
385
+ header: t("costs.split"),
373
386
  title: "Input · output · cache read · cache write",
374
387
  width: 150,
375
388
  render: row =>
@@ -384,7 +397,7 @@ function buildCostColumns(view: CostsView): Column<CostModelRow>[] {
384
397
  ...componentColumns,
385
398
  {
386
399
  key: "perRequest",
387
- header: "Per request",
400
+ header: t("costs.per-request"),
388
401
  title: "Estimate ÷ priced requests",
389
402
  align: "right",
390
403
  sort: row => (row.requests > row.unpricedRequests ? row.cost / (row.requests - row.unpricedRequests) : -1),
@@ -398,7 +411,7 @@ function buildCostColumns(view: CostsView): Column<CostModelRow>[] {
398
411
  },
399
412
  {
400
413
  key: "unpriced",
401
- header: "Unpriced",
414
+ header: t("costs.unpriced"),
402
415
  title: "Requests with no public-equivalent price",
403
416
  align: "right",
404
417
  sort: row => row.unpricedRequests,