@kopai/ui 0.5.0 → 0.6.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.
- package/README.md +23 -0
- package/dist/index.cjs +1591 -231
- package/dist/index.d.cts +559 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +559 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1590 -207
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -11
- package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +234 -0
- package/src/components/observability/DynamicDashboard/index.tsx +64 -0
- package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +10 -1
- package/src/components/observability/MetricHistogram/index.tsx +85 -19
- package/src/components/observability/MetricStat/MetricStat.stories.tsx +2 -1
- package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +23 -1
- package/src/components/observability/MetricTimeSeries/index.tsx +70 -27
- package/src/components/observability/__fixtures__/metrics.ts +97 -0
- package/src/components/observability/index.ts +3 -0
- package/src/components/observability/renderers/OtelLogTimeline.tsx +28 -0
- package/src/components/observability/renderers/OtelMetricHistogram.tsx +2 -0
- package/src/components/observability/renderers/OtelMetricStat.tsx +1 -13
- package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +2 -0
- package/src/components/observability/renderers/OtelTraceDetail.tsx +35 -0
- package/src/components/observability/renderers/index.ts +2 -0
- package/src/components/observability/utils/attributes.ts +7 -0
- package/src/components/observability/utils/units.test.ts +116 -0
- package/src/components/observability/utils/units.ts +132 -0
- package/src/index.ts +1 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +7 -1
- package/src/lib/generate-prompt-instructions.test.ts +1 -1
- package/src/lib/generate-prompt-instructions.ts +18 -6
- package/src/lib/observability-catalog.ts +7 -1
- package/src/lib/renderer.tsx +1 -1
- package/src/pages/observability.test.tsx +124 -0
- package/src/pages/observability.tsx +60 -34
- 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
|
|
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
|
|
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={
|
|
345
|
+
tickFormatter={tickFormatter}
|
|
307
346
|
stroke="#9CA3AF"
|
|
308
347
|
tick={{ fill: "#9CA3AF", fontSize: 12 }}
|
|
309
348
|
label={
|
|
310
|
-
|
|
349
|
+
resolvedYAxisLabel
|
|
311
350
|
? {
|
|
312
|
-
value:
|
|
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={
|
|
325
|
-
|
|
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
|
-
|
|
337
|
-
?
|
|
338
|
-
:
|
|
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 !==
|
|
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
|
-
|
|
449
|
+
displayLabelMap,
|
|
409
450
|
}: {
|
|
410
451
|
active?: boolean;
|
|
411
|
-
payload?:
|
|
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
|
-
|
|
456
|
+
displayLabelMap: Map<string, string>;
|
|
416
457
|
}) {
|
|
417
|
-
if (!active || !payload ||
|
|
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(
|
|
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">
|
|
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
|
+
});
|