@kopai/ui 0.5.0 → 0.7.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 (36) hide show
  1. package/README.md +23 -0
  2. package/dist/index.cjs +1591 -231
  3. package/dist/index.d.cts +559 -2
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +559 -2
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +1590 -207
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +12 -11
  10. package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +239 -0
  11. package/src/components/observability/DynamicDashboard/index.tsx +64 -0
  12. package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +10 -1
  13. package/src/components/observability/MetricHistogram/index.tsx +85 -19
  14. package/src/components/observability/MetricStat/MetricStat.stories.tsx +2 -1
  15. package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +23 -1
  16. package/src/components/observability/MetricTimeSeries/index.tsx +70 -27
  17. package/src/components/observability/__fixtures__/metrics.ts +97 -0
  18. package/src/components/observability/index.ts +3 -0
  19. package/src/components/observability/renderers/OtelLogTimeline.tsx +28 -0
  20. package/src/components/observability/renderers/OtelMetricHistogram.tsx +2 -0
  21. package/src/components/observability/renderers/OtelMetricStat.tsx +1 -13
  22. package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +2 -0
  23. package/src/components/observability/renderers/OtelTraceDetail.tsx +35 -0
  24. package/src/components/observability/renderers/index.ts +2 -0
  25. package/src/components/observability/utils/attributes.ts +7 -0
  26. package/src/components/observability/utils/units.test.ts +116 -0
  27. package/src/components/observability/utils/units.ts +132 -0
  28. package/src/index.ts +1 -0
  29. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +7 -1
  30. package/src/lib/generate-prompt-instructions.test.ts +1 -1
  31. package/src/lib/generate-prompt-instructions.ts +18 -6
  32. package/src/lib/observability-catalog.ts +7 -1
  33. package/src/lib/renderer.tsx +1 -1
  34. package/src/pages/observability.test.tsx +129 -0
  35. package/src/pages/observability.tsx +60 -34
  36. package/src/lib/dashboard-datasource.ts +0 -76
@@ -22,6 +22,12 @@ import type {
22
22
  RechartsDataPoint,
23
23
  } from "../types.js";
24
24
  import { downsampleLTTB, type LTTBPoint } from "../utils/lttb.js";
25
+ import { formatSeriesLabel } from "../utils/attributes.js";
26
+ import {
27
+ resolveUnitScale,
28
+ formatTickValue,
29
+ formatDisplayValue,
30
+ } from "../utils/units.js";
25
31
 
26
32
  type OtelMetricsRow = denormalizedSignals.OtelMetricsRow;
27
33
 
@@ -52,6 +58,7 @@ export interface MetricTimeSeriesProps {
52
58
  maxDataPoints?: number;
53
59
  showBrush?: boolean;
54
60
  height?: number;
61
+ unit?: string;
55
62
  yAxisLabel?: string;
56
63
  formatTime?: (timestamp: number) => string;
57
64
  formatValue?: (value: number) => string;
@@ -70,12 +77,6 @@ const defaultFormatTime = (timestamp: number): string => {
70
77
  });
71
78
  };
72
79
 
73
- const defaultFormatValue = (value: number): string => {
74
- if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
75
- if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
76
- return value.toFixed(2);
77
- };
78
-
79
80
  function getStrokeDashArray(
80
81
  style?: "solid" | "dashed" | "dotted"
81
82
  ): string | undefined {
@@ -185,6 +186,20 @@ function getSeriesKeys(metrics: ParsedMetricGroup[]): string[] {
185
186
  return Array.from(keys);
186
187
  }
187
188
 
189
+ function buildDisplayLabelMap(
190
+ metrics: ParsedMetricGroup[]
191
+ ): Map<string, string> {
192
+ const map = new Map<string, string>();
193
+ for (const m of metrics) {
194
+ for (const s of m.series) {
195
+ const dataKey = s.key === "__default__" ? m.name : s.key;
196
+ const label = formatSeriesLabel(s.labels);
197
+ map.set(dataKey, label || m.name);
198
+ }
199
+ }
200
+ return map;
201
+ }
202
+
188
203
  function downsampleRechartsData(
189
204
  data: RechartsDataPoint[],
190
205
  seriesKeys: string[],
@@ -212,9 +227,10 @@ export function MetricTimeSeries({
212
227
  maxDataPoints = 500,
213
228
  showBrush = true,
214
229
  height = 400,
230
+ unit: unitProp,
215
231
  yAxisLabel,
216
232
  formatTime = defaultFormatTime,
217
- formatValue = defaultFormatValue,
233
+ formatValue,
218
234
  onBrushChange,
219
235
  legendMaxLength = 30,
220
236
  thresholdLines,
@@ -222,7 +238,7 @@ export function MetricTimeSeries({
222
238
  const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(new Set());
223
239
 
224
240
  const parsedMetrics = useMemo(() => buildMetrics(rows), [rows]);
225
- const unit = parsedMetrics[0]?.unit ?? "";
241
+ const effectiveUnit = unitProp ?? parsedMetrics[0]?.unit ?? "";
226
242
  const chartData = useMemo(
227
243
  () => toRechartsData(parsedMetrics),
228
244
  [parsedMetrics]
@@ -231,11 +247,34 @@ export function MetricTimeSeries({
231
247
  () => getSeriesKeys(parsedMetrics),
232
248
  [parsedMetrics]
233
249
  );
250
+ const displayLabelMap = useMemo(
251
+ () => buildDisplayLabelMap(parsedMetrics),
252
+ [parsedMetrics]
253
+ );
234
254
  const displayData = useMemo(
235
255
  () => downsampleRechartsData(chartData, seriesKeys, maxDataPoints),
236
256
  [chartData, seriesKeys, maxDataPoints]
237
257
  );
238
258
 
259
+ const { tickFormatter, displayFormatter, resolvedYAxisLabel } =
260
+ useMemo(() => {
261
+ let max = 0;
262
+ for (const dp of displayData) {
263
+ for (const key of seriesKeys) {
264
+ const v = dp[key];
265
+ if (v !== undefined && Math.abs(v) > max) max = Math.abs(v);
266
+ }
267
+ }
268
+ const scale = resolveUnitScale(effectiveUnit, max);
269
+ return {
270
+ tickFormatter:
271
+ formatValue ?? ((v: number) => formatTickValue(v, scale)),
272
+ displayFormatter:
273
+ formatValue ?? ((v: number) => formatDisplayValue(v, scale)),
274
+ resolvedYAxisLabel: yAxisLabel ?? (scale.label || undefined),
275
+ };
276
+ }, [displayData, seriesKeys, effectiveUnit, formatValue, yAxisLabel]);
277
+
239
278
  const handleLegendClick = useCallback((dataKey: string) => {
240
279
  setHiddenSeries((prev) => {
241
280
  const next = new Set(prev);
@@ -303,13 +342,13 @@ export function MetricTimeSeries({
303
342
  tick={{ fill: "#9CA3AF", fontSize: 12 }}
304
343
  />
305
344
  <YAxis
306
- tickFormatter={formatValue}
345
+ tickFormatter={tickFormatter}
307
346
  stroke="#9CA3AF"
308
347
  tick={{ fill: "#9CA3AF", fontSize: 12 }}
309
348
  label={
310
- yAxisLabel
349
+ resolvedYAxisLabel
311
350
  ? {
312
- value: yAxisLabel,
351
+ value: resolvedYAxisLabel,
313
352
  angle: -90,
314
353
  position: "insideLeft",
315
354
  fill: "#9CA3AF",
@@ -318,13 +357,14 @@ export function MetricTimeSeries({
318
357
  }
319
358
  />
320
359
  <Tooltip
321
- content={
360
+ content={(props) => (
322
361
  <CustomTooltip
362
+ {...props}
323
363
  formatTime={formatTime}
324
- formatValue={formatValue}
325
- unit={unit}
364
+ formatValue={displayFormatter}
365
+ displayLabelMap={displayLabelMap}
326
366
  />
327
- }
367
+ )}
328
368
  />
329
369
  <Legend
330
370
  onClick={(e) => {
@@ -332,10 +372,11 @@ export function MetricTimeSeries({
332
372
  if (typeof dk === "string") handleLegendClick(dk);
333
373
  }}
334
374
  formatter={(value: string) => {
375
+ const label = displayLabelMap.get(value) ?? value;
335
376
  const truncated =
336
- value.length > legendMaxLength
337
- ? value.slice(0, legendMaxLength - 3) + "..."
338
- : value;
377
+ label.length > legendMaxLength
378
+ ? label.slice(0, legendMaxLength - 3) + "..."
379
+ : label;
339
380
  const isHidden = hiddenSeries.has(value);
340
381
  return (
341
382
  <span
@@ -344,7 +385,7 @@ export function MetricTimeSeries({
344
385
  textDecoration: isHidden ? "line-through" : "none",
345
386
  cursor: "pointer",
346
387
  }}
347
- title={truncated !== value ? value : undefined}
388
+ title={truncated !== label ? label : undefined}
348
389
  >
349
390
  {truncated}
350
391
  </span>
@@ -405,24 +446,26 @@ function CustomTooltip({
405
446
  label,
406
447
  formatTime,
407
448
  formatValue,
408
- unit,
449
+ displayLabelMap,
409
450
  }: {
410
451
  active?: boolean;
411
- payload?: Array<{ dataKey: string; value: number; color: string }>;
412
- label?: number;
452
+ payload?: readonly { dataKey: string; value: number; color: string }[];
453
+ label?: string | number;
413
454
  formatTime: (ts: number) => string;
414
455
  formatValue: (val: number) => string;
415
- unit?: string;
456
+ displayLabelMap: Map<string, string>;
416
457
  }) {
417
- if (!active || !payload || !label) return null;
458
+ if (!active || !payload || label == null) return null;
459
+ const ts = typeof label === "number" ? label : Number(label);
418
460
  return (
419
461
  <div className="bg-background border border-gray-700 rounded-lg p-3 shadow-lg">
420
- <p className="text-gray-400 text-xs mb-2">{formatTime(label)}</p>
462
+ <p className="text-gray-400 text-xs mb-2">{formatTime(ts)}</p>
421
463
  {payload.map((entry, i) => (
422
464
  <p key={i} className="text-sm" style={{ color: entry.color }}>
423
- <span className="font-medium">{entry.dataKey}:</span>{" "}
465
+ <span className="font-medium">
466
+ {displayLabelMap.get(entry.dataKey) ?? entry.dataKey}:
467
+ </span>{" "}
424
468
  {formatValue(entry.value)}
425
- {unit ? ` ${unit}` : ""}
426
469
  </p>
427
470
  ))}
428
471
  </div>
@@ -214,3 +214,100 @@ export const mockStatRows: OtelMetricsRow[] = cpuValues.map((value, i) => ({
214
214
  ScopeName: "opentelemetry.instrumentation.system",
215
215
  ScopeVersion: "0.44.0",
216
216
  }));
217
+
218
+ // ── Gauge: redis.memory.used (no attributes — reproduces {} label) ──
219
+
220
+ const memoryValues = [
221
+ 3100000, 3050000, 3200000, 3150000, 3300000, 3250000, 3400000, 3350000,
222
+ 3500000, 3450000, 3300000, 3200000, 3100000, 3050000, 3150000,
223
+ ];
224
+
225
+ export const mockNoAttributeRows: OtelMetricsRow[] = memoryValues.map(
226
+ (value, i) => ({
227
+ MetricType: "Gauge" as const,
228
+ MetricName: "redis.memory.used",
229
+ MetricDescription: "Total memory used by Redis",
230
+ MetricUnit: "By",
231
+ TimeUnix: ts(i * INTERVAL_MS),
232
+ StartTimeUnix: ts(0),
233
+ Value: value,
234
+ ServiceName: "redis",
235
+ Attributes: {},
236
+ ResourceAttributes: { "service.name": "redis" },
237
+ ScopeName: "opentelemetry.instrumentation.redis",
238
+ ScopeVersion: "0.44.0",
239
+ })
240
+ );
241
+
242
+ // ── Gauge: redis.memory.peak (~2-3 GB range — tests GB scaling) ──
243
+
244
+ const peakMemoryValues = [
245
+ 2_100_000_000, 2_200_000_000, 2_350_000_000, 2_500_000_000, 2_400_000_000,
246
+ 2_600_000_000, 2_750_000_000, 2_900_000_000, 2_800_000_000, 3_000_000_000,
247
+ 2_950_000_000, 2_700_000_000, 2_550_000_000, 2_400_000_000, 2_300_000_000,
248
+ ];
249
+
250
+ export const mockLargeByteRows: OtelMetricsRow[] = peakMemoryValues.map(
251
+ (value, i) => ({
252
+ MetricType: "Gauge" as const,
253
+ MetricName: "redis.memory.peak",
254
+ MetricDescription: "Peak memory consumed by Redis",
255
+ MetricUnit: "By",
256
+ TimeUnix: ts(i * INTERVAL_MS),
257
+ StartTimeUnix: ts(0),
258
+ Value: value,
259
+ ServiceName: "redis",
260
+ Attributes: {},
261
+ ResourceAttributes: { "service.name": "redis" },
262
+ ScopeName: "opentelemetry.instrumentation.redis",
263
+ ScopeVersion: "0.44.0",
264
+ })
265
+ );
266
+
267
+ // ── Gauge: process.cpu.time (multiple attributes — shows key=val labels) ──
268
+
269
+ const cpuTimeStates = ["sys", "user", "sys_children", "user_children"];
270
+ const cpuTimeBase = [27.5, 1.2, 0.3, 0.1];
271
+
272
+ export const mockMultiAttributeRows: OtelMetricsRow[] = cpuTimeStates.flatMap(
273
+ (state, si) =>
274
+ Array.from({ length: 10 }, (_, i) => ({
275
+ MetricType: "Gauge" as const,
276
+ MetricName: "process.cpu.time",
277
+ MetricDescription: "CPU time by state",
278
+ MetricUnit: "s",
279
+ TimeUnix: ts(i * INTERVAL_MS),
280
+ StartTimeUnix: ts(0),
281
+ Value: cpuTimeBase[si]! + ((si * 10 + i) % 7) * 0.07,
282
+ ServiceName: "api-gateway",
283
+ Attributes: { state, "cpu.mode": si < 2 ? "main" : "children" },
284
+ ResourceAttributes: { "service.name": "api-gateway" },
285
+ ScopeName: "opentelemetry.instrumentation.process",
286
+ ScopeVersion: "0.44.0",
287
+ }))
288
+ );
289
+
290
+ // ── Histogram: no attributes ──
291
+
292
+ export const mockNoAttributeHistogramRows: OtelMetricsRow[] = [
293
+ {
294
+ MetricType: "Histogram" as const,
295
+ MetricName: "http.server.request_duration",
296
+ MetricDescription: "Duration of HTTP server requests",
297
+ MetricUnit: "ms",
298
+ TimeUnix: ts(0),
299
+ StartTimeUnix: ts(0),
300
+ ServiceName: "api-gateway",
301
+ Count: 1500,
302
+ Sum: 87450,
303
+ Min: 1,
304
+ Max: 1850,
305
+ ExplicitBounds: [5, 10, 25, 50, 75, 100, 250, 500, 1000],
306
+ BucketCounts: [120, 280, 350, 310, 180, 120, 85, 35, 15, 5],
307
+ AggregationTemporality: "CUMULATIVE",
308
+ Attributes: {},
309
+ ResourceAttributes: { "service.name": "api-gateway" },
310
+ ScopeName: "opentelemetry.instrumentation.http",
311
+ ScopeVersion: "0.44.0",
312
+ },
313
+ ];
@@ -51,6 +51,9 @@ export type {
51
51
  ShortcutsRegistry,
52
52
  } from "../KeyboardShortcuts/index.js";
53
53
 
54
+ export { DynamicDashboard } from "./DynamicDashboard/index.js";
55
+ export type { DynamicDashboardProps } from "./DynamicDashboard/index.js";
56
+
54
57
  // Types
55
58
  export type {
56
59
  SpanNode,
@@ -0,0 +1,28 @@
1
+ import { observabilityCatalog } from "../../../lib/observability-catalog.js";
2
+ import type { RendererComponentProps } from "../../../lib/renderer.js";
3
+ import { LogTimeline } from "../index.js";
4
+ import type { denormalizedSignals } from "@kopai/core";
5
+
6
+ type OtelLogsRow = denormalizedSignals.OtelLogsRow;
7
+
8
+ type Props = RendererComponentProps<
9
+ typeof observabilityCatalog.components.LogTimeline
10
+ >;
11
+
12
+ export function OtelLogTimeline(props: Props) {
13
+ if (!props.hasData) {
14
+ return (
15
+ <div style={{ padding: 24, color: "var(--muted)" }}>No data source</div>
16
+ );
17
+ }
18
+
19
+ const response = props.data as { data?: OtelLogsRow[] } | null;
20
+
21
+ return (
22
+ <LogTimeline
23
+ rows={response?.data ?? []}
24
+ isLoading={props.loading}
25
+ error={props.error ?? undefined}
26
+ />
27
+ );
28
+ }
@@ -24,6 +24,8 @@ export function OtelMetricHistogram(props: Props) {
24
24
  isLoading={props.loading}
25
25
  error={props.error ?? undefined}
26
26
  height={props.element.props.height ?? 400}
27
+ yAxisLabel={props.element.props.yAxisLabel ?? undefined}
28
+ unit={props.element.props.unit ?? undefined}
27
29
  />
28
30
  );
29
31
  }
@@ -1,6 +1,7 @@
1
1
  import { observabilityCatalog } from "../../../lib/observability-catalog.js";
2
2
  import type { RendererComponentProps } from "../../../lib/renderer.js";
3
3
  import { MetricStat } from "../index.js";
4
+ import { formatOtelValue } from "../utils/units.js";
4
5
  import type { denormalizedSignals } from "@kopai/core";
5
6
 
6
7
  type OtelMetricsRow = denormalizedSignals.OtelMetricsRow;
@@ -9,19 +10,6 @@ type Props = RendererComponentProps<
9
10
  typeof observabilityCatalog.components.MetricStat
10
11
  >;
11
12
 
12
- function formatOtelValue(value: number, unit: string): string {
13
- // OTel unit "1" = dimensionless ratio → show as percentage
14
- if (unit === "1") return `${(value * 100).toFixed(1)}%`;
15
- // OTel curly-brace units like "{request}" → strip braces
16
- const cleanUnit = unit.replace(/^\{|\}$/g, "");
17
- let formatted: string;
18
- if (Math.abs(value) >= 1e6) formatted = `${(value / 1e6).toFixed(1)}M`;
19
- else if (Math.abs(value) >= 1e3) formatted = `${(value / 1e3).toFixed(1)}K`;
20
- else if (Number.isInteger(value)) formatted = value.toString();
21
- else formatted = value.toFixed(2);
22
- return cleanUnit ? `${formatted} ${cleanUnit}` : formatted;
23
- }
24
-
25
13
  export function OtelMetricStat(props: Props) {
26
14
  if (!props.hasData) {
27
15
  return (
@@ -25,6 +25,8 @@ export function OtelMetricTimeSeries(props: Props) {
25
25
  error={props.error ?? undefined}
26
26
  height={props.element.props.height ?? 400}
27
27
  showBrush={props.element.props.showBrush ?? true}
28
+ yAxisLabel={props.element.props.yAxisLabel ?? undefined}
29
+ unit={props.element.props.unit ?? undefined}
28
30
  />
29
31
  );
30
32
  }
@@ -0,0 +1,35 @@
1
+ import { observabilityCatalog } from "../../../lib/observability-catalog.js";
2
+ import type { RendererComponentProps } from "../../../lib/renderer.js";
3
+ import { TraceDetail } from "../index.js";
4
+ import type { denormalizedSignals } from "@kopai/core";
5
+
6
+ type OtelTracesRow = denormalizedSignals.OtelTracesRow;
7
+
8
+ type Props = RendererComponentProps<
9
+ typeof observabilityCatalog.components.TraceDetail
10
+ >;
11
+
12
+ export function OtelTraceDetail(props: Props) {
13
+ if (!props.hasData) {
14
+ return (
15
+ <div style={{ padding: 24, color: "var(--muted)" }}>No data source</div>
16
+ );
17
+ }
18
+
19
+ const response = props.data as { data?: OtelTracesRow[] } | null;
20
+ const rows = response?.data ?? [];
21
+ const firstRow = rows[0];
22
+ const service = firstRow?.ServiceName ?? "unknown";
23
+ const traceId = firstRow?.TraceId ?? "";
24
+
25
+ return (
26
+ <TraceDetail
27
+ rows={rows}
28
+ isLoading={props.loading}
29
+ error={props.error ?? undefined}
30
+ service={service}
31
+ traceId={traceId}
32
+ onBack={() => {}}
33
+ />
34
+ );
35
+ }
@@ -1,5 +1,7 @@
1
+ export { OtelLogTimeline } from "./OtelLogTimeline.js";
1
2
  export { OtelMetricDiscovery } from "./OtelMetricDiscovery.js";
2
3
  export { OtelMetricHistogram } from "./OtelMetricHistogram.js";
3
4
  export { OtelMetricStat } from "./OtelMetricStat.js";
4
5
  export { OtelMetricTable } from "./OtelMetricTable.js";
5
6
  export { OtelMetricTimeSeries } from "./OtelMetricTimeSeries.js";
7
+ export { OtelTraceDetail } from "./OtelTraceDetail.js";
@@ -8,6 +8,13 @@ export function formatAttributeValue(value: unknown): string {
8
8
  return String(value);
9
9
  }
10
10
 
11
+ export function formatSeriesLabel(labels: Record<string, string>): string {
12
+ const entries = Object.entries(labels);
13
+ if (entries.length === 0) return "";
14
+ if (entries.length === 1) return String(entries[0]![1]);
15
+ return entries.map(([k, v]) => `${k}=${v}`).join(", ");
16
+ }
17
+
11
18
  export function isComplexValue(value: unknown): boolean {
12
19
  return (
13
20
  typeof value === "object" &&
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ resolveUnitScale,
4
+ formatTickValue,
5
+ formatDisplayValue,
6
+ formatOtelValue,
7
+ } from "./units.js";
8
+
9
+ describe("resolveUnitScale", () => {
10
+ it("scales bytes to MB", () => {
11
+ const s = resolveUnitScale("By", 3_500_000);
12
+ expect(s.suffix).toBe("MB");
13
+ expect(s.divisor).toBe(1e6);
14
+ expect(s.label).toBe("MB");
15
+ });
16
+
17
+ it("keeps small bytes as B", () => {
18
+ const s = resolveUnitScale("By", 500);
19
+ expect(s.suffix).toBe("B");
20
+ expect(s.divisor).toBe(1);
21
+ });
22
+
23
+ it("scales bytes to GB", () => {
24
+ const s = resolveUnitScale("By", 2e9);
25
+ expect(s.suffix).toBe("GB");
26
+ expect(s.divisor).toBe(1e9);
27
+ });
28
+
29
+ it("scales seconds to ms for small values", () => {
30
+ const s = resolveUnitScale("s", 0.005);
31
+ expect(s.suffix).toBe("ms");
32
+ expect(s.divisor).toBe(0.001);
33
+ });
34
+
35
+ it("keeps seconds as s for values >= 1", () => {
36
+ const s = resolveUnitScale("s", 5);
37
+ expect(s.suffix).toBe("s");
38
+ });
39
+
40
+ it("scales ms to s for large values", () => {
41
+ const s = resolveUnitScale("ms", 5000);
42
+ expect(s.suffix).toBe("s");
43
+ });
44
+
45
+ it("handles unit '1' as percent", () => {
46
+ const s = resolveUnitScale("1", 0.75);
47
+ expect(s.isPercent).toBe(true);
48
+ expect(s.suffix).toBe("%");
49
+ expect(s.label).toBe("Percent");
50
+ });
51
+
52
+ it("handles curly-brace units", () => {
53
+ const s = resolveUnitScale("{requests}", 1_500_000);
54
+ expect(s.suffix).toBe("M requests");
55
+ expect(s.label).toBe("requests");
56
+ });
57
+
58
+ it("handles empty/null unit with generic scaling", () => {
59
+ const s = resolveUnitScale("", 1_500_000);
60
+ expect(s.suffix).toBe("M");
61
+ expect(s.label).toBe("");
62
+ });
63
+
64
+ it("handles null unit", () => {
65
+ const s = resolveUnitScale(null, 500);
66
+ expect(s.suffix).toBe("");
67
+ });
68
+ });
69
+
70
+ describe("formatTickValue", () => {
71
+ it("formats scaled byte value", () => {
72
+ const s = resolveUnitScale("By", 3_500_000);
73
+ expect(formatTickValue(1_400_000, s)).toBe("1.4");
74
+ });
75
+
76
+ it("formats percent value", () => {
77
+ const s = resolveUnitScale("1", 0.75);
78
+ expect(formatTickValue(0.75, s)).toBe("75.0");
79
+ });
80
+ });
81
+
82
+ describe("formatDisplayValue", () => {
83
+ it("includes suffix for bytes", () => {
84
+ const s = resolveUnitScale("By", 3_500_000);
85
+ expect(formatDisplayValue(1_400_000, s)).toBe("1.4 MB");
86
+ });
87
+
88
+ it("includes suffix for percent", () => {
89
+ const s = resolveUnitScale("1", 0.75);
90
+ expect(formatDisplayValue(0.75, s)).toBe("75.0%");
91
+ });
92
+
93
+ it("includes suffix for curly-brace units", () => {
94
+ const s = resolveUnitScale("{requests}", 1_500_000);
95
+ expect(formatDisplayValue(1_500_000, s)).toBe("1.5 M requests");
96
+ });
97
+
98
+ it("no suffix for empty unit small values", () => {
99
+ const s = resolveUnitScale("", 50);
100
+ expect(formatDisplayValue(42, s)).toBe("42");
101
+ });
102
+ });
103
+
104
+ describe("formatOtelValue", () => {
105
+ it("convenience: bytes", () => {
106
+ expect(formatOtelValue(3_500_000, "By")).toBe("3.5 MB");
107
+ });
108
+
109
+ it("convenience: percent", () => {
110
+ expect(formatOtelValue(0.75, "1")).toBe("75.0%");
111
+ });
112
+
113
+ it("convenience: small value no unit", () => {
114
+ expect(formatOtelValue(42, "")).toBe("42");
115
+ });
116
+ });