@linxiraos/pi-stats 1.1.26 → 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 (125) hide show
  1. package/CHANGELOG.md +12 -5
  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 +7 -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 +13 -1
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +5 -5
  51. package/src/aggregator.ts +31 -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 +80 -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 +90 -73
  90. package/src/client/routes/OverviewRoute.tsx +109 -63
  91. package/src/client/routes/ProjectsRoute.tsx +73 -46
  92. package/src/client/routes/ProvidersRoute.tsx +151 -114
  93. package/src/client/routes/RequestsRoute.tsx +69 -46
  94. package/src/client/routes/ToolsRoute.tsx +108 -94
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +5 -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/index.ts +2 -1
  115. package/src/live.ts +36 -11
  116. package/src/locale.ts +29 -0
  117. package/src/parser.ts +37 -72
  118. package/src/port-conflict.ts +1 -1
  119. package/src/prompts/judge/frustration.json +19 -0
  120. package/src/rollup.ts +41 -20
  121. package/src/server.ts +67 -27
  122. package/src/shared-types.ts +7 -0
  123. package/src/trace.ts +54 -40
  124. package/src/types.ts +13 -1
  125. package/src/user-metrics.ts +220 -6
@@ -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,
@@ -1,6 +1,7 @@
1
1
  import { ArrowUpRight, X } from "lucide-react";
2
2
  import { useMemo, useState } from "react";
3
3
  import { getRecentErrors } from "../api";
4
+ import { t } from "../i18n";
4
5
  import { BarList } from "../charts";
5
6
  import { modelKey } from "../data/colors";
6
7
  import {
@@ -44,7 +45,9 @@ const LOAD_STEPS = [50, 200, 1_000] as const;
44
45
  export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps) {
45
46
  const [step, setStep] = useState(0);
46
47
  const limit = LOAD_STEPS[step];
47
- const errors = useQuery(["errors", range, limit], () => getRecentErrors(range, limit), { enabled: active });
48
+ const errors = useQuery(["errors", range, limit], ({ signal }) => getRecentErrors(range, limit, signal), {
49
+ enabled: active,
50
+ });
48
51
  const [selectedSignature, setSelectedSignature] = useState<string | null>(null);
49
52
  const [selectedModel, setSelectedModel] = useState<string | null>(null);
50
53
  const [search, setSearch] = useState("");
@@ -97,9 +100,7 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
97
100
  const footer =
98
101
  errors.data !== null && !complete ? (
99
102
  <>
100
- <span>
101
- Showing the latest {formatInteger(loaded)} failures in {meta.windowLabel}; older ones are not loaded.
102
- </span>
103
+ <span>{t("errors.footer", { count: formatInteger(loaded), window: t(meta.windowKey) })}</span>
103
104
  {nextStep !== null && (
104
105
  <button
105
106
  type="button"
@@ -108,7 +109,9 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
108
109
  disabled={errors.refreshing}
109
110
  onClick={() => setStep(step + 1)}
110
111
  >
111
- {errors.refreshing && errors.stale ? "Loading…" : `Load latest ${formatInteger(nextStep)}`}
112
+ {errors.refreshing && errors.stale
113
+ ? t("errors.loading")
114
+ : t("errors.load-latest", { count: formatInteger(nextStep) })}
112
115
  </button>
113
116
  )}
114
117
  </>
@@ -120,35 +123,44 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
120
123
 
121
124
  return (
122
125
  <div className="page">
123
- <PageHeader
124
- title="Errors"
125
- description={`Failed model requests in ${meta.windowLabel}, grouped by error signature. Open a failure for its full payload.`}
126
- />
126
+ <PageHeader title={t("errors.title")} description={t("errors.description", { window: t(meta.windowKey) })} />
127
127
 
128
128
  <QueryView query={errors} skeleton={<Skeleton height={96} style={{ borderRadius: 12 }} />}>
129
129
  {() => (
130
130
  <div data-stale={errors.stale}>
131
131
  <StatGrid min={170}>
132
132
  <Stat
133
- label="Failures"
133
+ label={t("errors.failures")}
134
134
  value={formatInteger(loaded)}
135
- hint={complete ? `in ${meta.windowLabel}` : `latest ${formatInteger(loaded)} loaded`}
135
+ hint={
136
+ complete
137
+ ? t("errors.hint-in-window", { window: t(meta.windowKey) })
138
+ : t("errors.hint-latest-loaded", { count: formatInteger(loaded) })
139
+ }
136
140
  />
137
141
  <Stat
138
- label="Signatures"
139
- title="Distinct error messages after normalizing ids and numbers"
142
+ label={t("errors.signatures")}
143
+ title={t("errors.signatures-hint")}
140
144
  value={formatInteger(view.groups.length)}
141
- hint={view.groups[0] ? `top: ${formatInteger(view.groups[0].count)} failures` : undefined}
145
+ hint={
146
+ view.groups[0]
147
+ ? t("errors.signatures-top", { count: formatInteger(view.groups[0].count) })
148
+ : undefined
149
+ }
142
150
  />
143
151
  <Stat
144
- label="Affected models"
152
+ label={t("errors.affected-models")}
145
153
  value={formatInteger(view.models.length)}
146
154
  hint={view.models[0]?.model}
147
155
  />
148
156
  <Stat
149
- label="Last failure"
157
+ label={t("errors.last-failure")}
150
158
  value={view.newest ? formatRelativeTime(view.newest.timestamp) : "–"}
151
- hint={view.newest ? formatTimestamp(view.newest.timestamp) : `none in ${meta.windowLabel}`}
159
+ hint={
160
+ view.newest
161
+ ? formatTimestamp(view.newest.timestamp)
162
+ : t("errors.none-in-window", { window: t(meta.windowKey) })
163
+ }
152
164
  />
153
165
  </StatGrid>
154
166
  </div>
@@ -158,8 +170,8 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
158
170
  <div className="grid grid-main-side">
159
171
  <Card
160
172
  index={1}
161
- title="Error signatures"
162
- description="Same message with ids and counters normalized. Select one to filter the failures below."
173
+ title={t("errors.error-signatures")}
174
+ description={t("errors.signatures-card-description")}
163
175
  flush
164
176
  stale={errors.stale}
165
177
  >
@@ -169,8 +181,8 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
169
181
  isEmpty={rows => rows.length === 0}
170
182
  empty={
171
183
  <EmptyState
172
- title={`No failures in ${meta.windowLabel}`}
173
- hint="Every request succeeded or was aborted."
184
+ title={t("errors.empty-no-failures", { window: t(meta.windowKey) })}
185
+ hint={t("errors.empty-all-succeeded")}
174
186
  />
175
187
  }
176
188
  >
@@ -196,15 +208,15 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
196
208
 
197
209
  <Card
198
210
  index={2}
199
- title="By model"
200
- description="Failures per model. Select one to filter."
211
+ title={t("errors.by-model")}
212
+ description={t("errors.by-model-description")}
201
213
  stale={errors.stale}
202
214
  >
203
215
  <QueryView
204
216
  query={errors}
205
217
  skeleton={<Skeleton height={180} />}
206
218
  isEmpty={rows => rows.length === 0}
207
- empty={<EmptyState title="No affected models" />}
219
+ empty={<EmptyState title={t("errors.empty-no-affected-models")} />}
208
220
  >
209
221
  {() => (
210
222
  <BarList
@@ -228,11 +240,14 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
228
240
 
229
241
  <Card
230
242
  index={3}
231
- title="Failures"
243
+ title={t("errors.failures")}
232
244
  description={
233
245
  errors.data === null
234
- ? "Loading failures…"
235
- : `${formatInteger(filtered.length)} of ${formatInteger(loaded)} failures, newest first`
246
+ ? t("errors.loading-failures")
247
+ : t("errors.filtered-of-total", {
248
+ shown: formatInteger(filtered.length),
249
+ total: formatInteger(loaded),
250
+ })
236
251
  }
237
252
  actions={
238
253
  <>
@@ -259,7 +274,7 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
259
274
  <X size={12} />
260
275
  </button>
261
276
  )}
262
- <SearchInput value={search} onChange={setSearch} placeholder="Message, model or project" />
277
+ <SearchInput value={search} onChange={setSearch} placeholder={t("errors.search-placeholder")} />
263
278
  </>
264
279
  }
265
280
  flush
@@ -270,7 +285,7 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
270
285
  query={errors}
271
286
  skeleton={<TableSkeleton rows={10} />}
272
287
  isEmpty={rows => rows.length === 0}
273
- empty={<EmptyState title={`No failures in ${meta.windowLabel}`} />}
288
+ empty={<EmptyState title={t("errors.empty-no-failures", { window: t(meta.windowKey) })} />}
274
289
  >
275
290
  {() => (
276
291
  <Table
@@ -281,7 +296,7 @@ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps)
281
296
  initialSort={{ key: "time", dir: "desc" }}
282
297
  limit={50}
283
298
  dense
284
- empty={<EmptyState title="No failures match" hint="Clear the search or the selected filters." />}
299
+ empty={<EmptyState title={t("errors.no-match")} hint={t("errors.no-match-hint")} />}
285
300
  />
286
301
  )}
287
302
  </QueryView>
@@ -294,7 +309,7 @@ function buildGroupColumns(maxCount: number): Column<ErrorGroupView>[] {
294
309
  return [
295
310
  {
296
311
  key: "signature",
297
- header: "Signature",
312
+ header: t("errors.signature"),
298
313
  wrap: true,
299
314
  sort: group => group.signature,
300
315
  render: group => (
@@ -305,18 +320,22 @@ function buildGroupColumns(maxCount: number): Column<ErrorGroupView>[] {
305
320
  },
306
321
  {
307
322
  key: "models",
308
- header: "Models",
323
+ header: t("common.models"),
309
324
  sort: group => group.models.length,
310
325
  render: group => (
311
326
  <LabelCell
312
327
  primary={<span className="mono">{group.models[0]?.model}</span>}
313
- secondary={group.models.length > 1 ? `+${group.models.length - 1} more` : group.models[0]?.provider}
328
+ secondary={
329
+ group.models.length > 1
330
+ ? t("errors.more-models", { count: formatInteger(group.models.length - 1) })
331
+ : group.models[0]?.provider
332
+ }
314
333
  />
315
334
  ),
316
335
  },
317
336
  {
318
337
  key: "last",
319
- header: "Last seen",
338
+ header: t("errors.last-seen"),
320
339
  sort: group => group.lastSeen,
321
340
  render: group => (
322
341
  <span className="muted" title={formatTimestamp(group.lastSeen)}>
@@ -326,7 +345,7 @@ function buildGroupColumns(maxCount: number): Column<ErrorGroupView>[] {
326
345
  },
327
346
  {
328
347
  key: "count",
329
- header: "Failures",
348
+ header: t("errors.failures"),
330
349
  align: "right",
331
350
  width: 120,
332
351
  sort: group => group.count,
@@ -341,15 +360,20 @@ function SignatureDetail({ group, onRequestClick }: { group: ErrorGroupView; onR
341
360
  const latestId = group.latest.id;
342
361
  return (
343
362
  <div className="errors-detail">
344
- <pre className="code-block errors-detail-message">{group.latest.errorMessage ?? "Unknown error"}</pre>
363
+ <pre className="code-block errors-detail-message">
364
+ {group.latest.errorMessage ?? t("errors.unknown-error")}
365
+ </pre>
345
366
  <div className="errors-detail-meta">
346
367
  <span className="muted">
347
- {formatInteger(group.count)} failures · first {formatTimestamp(group.firstSeen)} · last{" "}
348
- {formatTimestamp(group.lastSeen)}
368
+ {t("errors.group-meta", {
369
+ count: formatInteger(group.count),
370
+ first: formatTimestamp(group.firstSeen),
371
+ last: formatTimestamp(group.lastSeen),
372
+ })}
349
373
  </span>
350
374
  {latestId !== undefined && (
351
375
  <button type="button" className="btn" data-size="sm" onClick={() => onRequestClick(latestId)}>
352
- Open latest <ArrowUpRight size={13} />
376
+ {t("errors.open-latest")} <ArrowUpRight size={13} />
353
377
  </button>
354
378
  )}
355
379
  </div>
@@ -368,7 +392,7 @@ function SignatureDetail({ group, onRequestClick }: { group: ErrorGroupView; onR
368
392
  const FAILURE_COLUMNS: readonly Column<MessageStats>[] = [
369
393
  {
370
394
  key: "time",
371
- header: "When",
395
+ header: t("errors.when"),
372
396
  width: 120,
373
397
  sort: row => row.timestamp,
374
398
  render: row => (
@@ -379,23 +403,23 @@ const FAILURE_COLUMNS: readonly Column<MessageStats>[] = [
379
403
  },
380
404
  {
381
405
  key: "model",
382
- header: "Model",
406
+ header: t("common.model"),
383
407
  sort: row => row.model,
384
408
  render: row => <LabelCell primary={<span className="mono">{row.model}</span>} secondary={row.provider} />,
385
409
  },
386
410
  {
387
411
  key: "error",
388
- header: "Error",
412
+ header: t("common.error"),
389
413
  sort: row => row.errorMessage ?? "",
390
414
  render: row => (
391
415
  <span className="errors-message" title={row.errorMessage ?? undefined}>
392
- {row.errorMessage ?? "Unknown error"}
416
+ {row.errorMessage ?? t("errors.unknown-error")}
393
417
  </span>
394
418
  ),
395
419
  },
396
420
  {
397
421
  key: "project",
398
- header: "Project",
422
+ header: t("common.project"),
399
423
  sort: row => row.folder,
400
424
  render: row => (
401
425
  <span className="mono muted" title={row.folder}>
@@ -405,15 +429,15 @@ const FAILURE_COLUMNS: readonly Column<MessageStats>[] = [
405
429
  },
406
430
  {
407
431
  key: "tokens",
408
- header: "Tokens",
432
+ header: t("common.tokens"),
409
433
  align: "right",
410
434
  sort: row => row.usage.totalTokens,
411
435
  render: row => <span className="num">{formatInteger(row.usage.totalTokens)}</span>,
412
436
  },
413
437
  {
414
438
  key: "cost",
415
- header: "Cost",
416
- title: "API-equivalent estimate",
439
+ header: t("common.cost"),
440
+ title: t("errors.cost-hint"),
417
441
  align: "right",
418
442
  sort: row => row.usage.cost.total,
419
443
  render: row => <span className="num">{formatMessageCost(row, 4)}</span>,