@kopai/ui 0.8.0 → 0.10.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/dist/index.cjs +2704 -1288
- package/dist/index.d.cts +38 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +38 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2722 -1300
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -13
- package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +7 -7
- package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +8 -0
- package/src/components/observability/LogTimeline/LogFilter.tsx +5 -1
- package/src/components/observability/LogTimeline/index.tsx +6 -2
- package/src/components/observability/MetricHistogram/index.tsx +20 -19
- package/src/components/observability/MetricStat/index.tsx +12 -4
- package/src/components/observability/MetricTimeSeries/index.tsx +8 -9
- package/src/components/observability/ServiceList/shortcuts.ts +1 -1
- package/src/components/observability/TraceComparison/index.tsx +332 -0
- package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
- package/src/components/observability/TraceDetail/index.tsx +4 -3
- package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
- package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
- package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
- package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
- package/src/components/observability/TraceSearch/index.tsx +211 -218
- package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
- package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
- package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
- package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
- package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
- package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
- package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
- package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
- package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
- package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
- package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
- package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
- package/src/components/observability/TraceTimeline/index.tsx +254 -110
- package/src/components/observability/index.ts +15 -0
- package/src/components/observability/renderers/OtelMetricStat.tsx +40 -0
- package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
- package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
- package/src/components/observability/utils/flatten-tree.ts +15 -0
- package/src/components/observability/utils/time.ts +9 -0
- package/src/hooks/use-kopai-data.test.ts +34 -0
- package/src/hooks/use-kopai-data.ts +23 -5
- package/src/hooks/use-live-logs.test.ts +4 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
- package/src/lib/component-catalog.ts +15 -0
- package/src/lib/renderer.test.tsx +2 -0
- package/src/pages/observability.test.tsx +8 -0
- package/src/pages/observability.tsx +397 -236
- package/src/providers/kopai-provider.tsx +4 -0
|
@@ -19,9 +19,9 @@ import {
|
|
|
19
19
|
LogTimeline,
|
|
20
20
|
LogFilter,
|
|
21
21
|
TabBar,
|
|
22
|
-
ServiceList,
|
|
23
22
|
TraceSearch,
|
|
24
23
|
TraceDetail,
|
|
24
|
+
TraceComparison,
|
|
25
25
|
KeyboardShortcutsProvider,
|
|
26
26
|
useRegisterShortcuts,
|
|
27
27
|
DynamicDashboard,
|
|
@@ -43,7 +43,7 @@ type OtelTracesRow = denormalizedSignals.OtelTracesRow;
|
|
|
43
43
|
type Tab = "logs" | "services" | "metrics";
|
|
44
44
|
|
|
45
45
|
const TABS: { key: Tab; label: string; shortcutKey: string }[] = [
|
|
46
|
-
{ key: "services", label: "
|
|
46
|
+
{ key: "services", label: "Traces", shortcutKey: "T" },
|
|
47
47
|
{ key: "logs", label: "Logs", shortcutKey: "L" },
|
|
48
48
|
{ key: "metrics", label: "Metrics", shortcutKey: "M" },
|
|
49
49
|
];
|
|
@@ -54,9 +54,28 @@ const TABS: { key: Tab; label: string; shortcutKey: string }[] = [
|
|
|
54
54
|
|
|
55
55
|
interface URLState {
|
|
56
56
|
tab: Tab;
|
|
57
|
+
// Services tab search params
|
|
57
58
|
service: string | null;
|
|
59
|
+
operation: string | null;
|
|
60
|
+
tags: string | null;
|
|
61
|
+
lookback: string | null;
|
|
62
|
+
tsMin: string | null;
|
|
63
|
+
tsMax: string | null;
|
|
64
|
+
minDuration: string | null;
|
|
65
|
+
maxDuration: string | null;
|
|
66
|
+
limit: number | null;
|
|
67
|
+
sort: string | null;
|
|
68
|
+
// Trace detail
|
|
58
69
|
trace: string | null;
|
|
59
70
|
span: string | null;
|
|
71
|
+
view: string | null;
|
|
72
|
+
uiFind: string | null;
|
|
73
|
+
// Comparison
|
|
74
|
+
compare: string | null;
|
|
75
|
+
// Minimap (phase 8)
|
|
76
|
+
viewStart: string | null;
|
|
77
|
+
viewEnd: string | null;
|
|
78
|
+
// Existing
|
|
60
79
|
dashboardId: string | null;
|
|
61
80
|
}
|
|
62
81
|
|
|
@@ -72,24 +91,79 @@ function readURLState(): URLState {
|
|
|
72
91
|
: rawTab === "logs" || rawTab === "metrics"
|
|
73
92
|
? rawTab
|
|
74
93
|
: "services";
|
|
75
|
-
|
|
94
|
+
const rawLimit = params.get("limit");
|
|
95
|
+
const limit = rawLimit ? parseInt(rawLimit, 10) : null;
|
|
96
|
+
return {
|
|
97
|
+
tab,
|
|
98
|
+
service,
|
|
99
|
+
operation: params.get("operation"),
|
|
100
|
+
tags: params.get("tags"),
|
|
101
|
+
lookback: params.get("lookback"),
|
|
102
|
+
tsMin: params.get("tsMin"),
|
|
103
|
+
tsMax: params.get("tsMax"),
|
|
104
|
+
minDuration: params.get("minDuration"),
|
|
105
|
+
maxDuration: params.get("maxDuration"),
|
|
106
|
+
limit: limit !== null && !isNaN(limit) ? limit : null,
|
|
107
|
+
sort: params.get("sort"),
|
|
108
|
+
trace,
|
|
109
|
+
span,
|
|
110
|
+
view: params.get("view"),
|
|
111
|
+
uiFind: params.get("uiFind"),
|
|
112
|
+
compare: params.get("compare"),
|
|
113
|
+
viewStart: params.get("viewStart"),
|
|
114
|
+
viewEnd: params.get("viewEnd"),
|
|
115
|
+
dashboardId,
|
|
116
|
+
};
|
|
76
117
|
}
|
|
77
118
|
|
|
78
119
|
function pushURLState(
|
|
79
120
|
state: {
|
|
80
121
|
tab: Tab;
|
|
81
122
|
service?: string | null;
|
|
123
|
+
operation?: string | null;
|
|
124
|
+
tags?: string | null;
|
|
125
|
+
lookback?: string | null;
|
|
126
|
+
tsMin?: string | null;
|
|
127
|
+
tsMax?: string | null;
|
|
128
|
+
minDuration?: string | null;
|
|
129
|
+
maxDuration?: string | null;
|
|
130
|
+
limit?: number | null;
|
|
131
|
+
sort?: string | null;
|
|
82
132
|
trace?: string | null;
|
|
83
133
|
span?: string | null;
|
|
134
|
+
view?: string | null;
|
|
135
|
+
uiFind?: string | null;
|
|
136
|
+
compare?: string | null;
|
|
137
|
+
viewStart?: string | null;
|
|
138
|
+
viewEnd?: string | null;
|
|
84
139
|
dashboardId?: string | null;
|
|
85
140
|
},
|
|
86
141
|
{ replace = false }: { replace?: boolean } = {}
|
|
87
142
|
) {
|
|
88
143
|
const params = new URLSearchParams();
|
|
89
144
|
if (state.tab !== "services") params.set("tab", state.tab);
|
|
90
|
-
|
|
91
|
-
if (state.
|
|
92
|
-
|
|
145
|
+
|
|
146
|
+
if (state.tab === "services") {
|
|
147
|
+
if (state.service) params.set("service", state.service);
|
|
148
|
+
if (state.operation) params.set("operation", state.operation);
|
|
149
|
+
if (state.tags) params.set("tags", state.tags);
|
|
150
|
+
if (state.lookback) params.set("lookback", state.lookback);
|
|
151
|
+
if (state.tsMin) params.set("tsMin", state.tsMin);
|
|
152
|
+
if (state.tsMax) params.set("tsMax", state.tsMax);
|
|
153
|
+
if (state.minDuration) params.set("minDuration", state.minDuration);
|
|
154
|
+
if (state.maxDuration) params.set("maxDuration", state.maxDuration);
|
|
155
|
+
if (state.limit != null && state.limit !== 20)
|
|
156
|
+
params.set("limit", String(state.limit));
|
|
157
|
+
if (state.sort) params.set("sort", state.sort);
|
|
158
|
+
if (state.trace) params.set("trace", state.trace);
|
|
159
|
+
if (state.span) params.set("span", state.span);
|
|
160
|
+
if (state.view) params.set("view", state.view);
|
|
161
|
+
if (state.uiFind) params.set("uiFind", state.uiFind);
|
|
162
|
+
if (state.compare) params.set("compare", state.compare);
|
|
163
|
+
if (state.viewStart) params.set("viewStart", state.viewStart);
|
|
164
|
+
if (state.viewEnd) params.set("viewEnd", state.viewEnd);
|
|
165
|
+
}
|
|
166
|
+
|
|
93
167
|
// Preserve dashboardId from current URL if not explicitly provided
|
|
94
168
|
const dashboardId =
|
|
95
169
|
state.dashboardId !== undefined
|
|
@@ -115,8 +189,22 @@ let _cachedSearch = "";
|
|
|
115
189
|
let _cachedState: URLState = {
|
|
116
190
|
tab: "services",
|
|
117
191
|
service: null,
|
|
192
|
+
operation: null,
|
|
193
|
+
tags: null,
|
|
194
|
+
lookback: null,
|
|
195
|
+
tsMin: null,
|
|
196
|
+
tsMax: null,
|
|
197
|
+
minDuration: null,
|
|
198
|
+
maxDuration: null,
|
|
199
|
+
limit: null,
|
|
200
|
+
sort: null,
|
|
118
201
|
trace: null,
|
|
119
202
|
span: null,
|
|
203
|
+
view: null,
|
|
204
|
+
uiFind: null,
|
|
205
|
+
compare: null,
|
|
206
|
+
viewStart: null,
|
|
207
|
+
viewEnd: null,
|
|
120
208
|
dashboardId: null,
|
|
121
209
|
};
|
|
122
210
|
|
|
@@ -276,6 +364,42 @@ function parseDuration(input: string): string | undefined {
|
|
|
276
364
|
return String(Math.round(value * multipliers[unit]!));
|
|
277
365
|
}
|
|
278
366
|
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Logfmt helpers
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
function parseLogfmt(str: string): Record<string, string> {
|
|
372
|
+
const result: Record<string, string> = {};
|
|
373
|
+
const re = /(\w+)=(?:"([^"]*)"|([\S]*))/g;
|
|
374
|
+
let m: RegExpExecArray | null;
|
|
375
|
+
while ((m = re.exec(str)) !== null) {
|
|
376
|
+
const key = m[1];
|
|
377
|
+
if (key) result[key] = m[2] ?? m[3] ?? "";
|
|
378
|
+
}
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function serializeLogfmt(rec: Record<string, string>): string {
|
|
383
|
+
return Object.entries(rec)
|
|
384
|
+
.map(([k, v]) => (v.includes(" ") ? `${k}="${v}"` : `${k}=${v}`))
|
|
385
|
+
.join(" ");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Lookback presets (ms values)
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
const LOOKBACK_MS: Record<string, number> = {
|
|
393
|
+
"5m": 5 * 60_000,
|
|
394
|
+
"15m": 15 * 60_000,
|
|
395
|
+
"30m": 30 * 60_000,
|
|
396
|
+
"1h": 60 * 60_000,
|
|
397
|
+
"2h": 2 * 60 * 60_000,
|
|
398
|
+
"6h": 6 * 60 * 60_000,
|
|
399
|
+
"12h": 12 * 60 * 60_000,
|
|
400
|
+
"24h": 24 * 60 * 60_000,
|
|
401
|
+
};
|
|
402
|
+
|
|
279
403
|
// ---------------------------------------------------------------------------
|
|
280
404
|
// Logs tab (live-tailing)
|
|
281
405
|
// ---------------------------------------------------------------------------
|
|
@@ -357,212 +481,159 @@ function LogsTab() {
|
|
|
357
481
|
// Services tab — data-fetching wrappers around extracted UI components
|
|
358
482
|
// ---------------------------------------------------------------------------
|
|
359
483
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}) {
|
|
370
|
-
const { data, loading, error } = useKopaiData<{
|
|
371
|
-
data: OtelTracesRow[];
|
|
372
|
-
nextCursor: string | null;
|
|
373
|
-
}>(SERVICES_DS);
|
|
374
|
-
|
|
375
|
-
const services = useMemo(() => {
|
|
376
|
-
if (!data?.data) return [];
|
|
377
|
-
const names = new Set<string>();
|
|
378
|
-
for (const row of data.data) {
|
|
379
|
-
names.add(row.ServiceName ?? "unknown");
|
|
380
|
-
}
|
|
381
|
-
return Array.from(names)
|
|
382
|
-
.sort()
|
|
383
|
-
.map((name) => ({ name }));
|
|
384
|
-
}, [data]);
|
|
385
|
-
|
|
386
|
-
return (
|
|
387
|
-
<ServiceList
|
|
388
|
-
services={services}
|
|
389
|
-
isLoading={loading}
|
|
390
|
-
error={error ?? undefined}
|
|
391
|
-
onSelect={onSelect}
|
|
392
|
-
/>
|
|
393
|
-
);
|
|
484
|
+
interface TraceSummaryRow {
|
|
485
|
+
traceId: string;
|
|
486
|
+
rootServiceName: string;
|
|
487
|
+
rootSpanName: string;
|
|
488
|
+
startTimeNs: string;
|
|
489
|
+
durationNs: string;
|
|
490
|
+
spanCount: number;
|
|
491
|
+
errorCount: number;
|
|
492
|
+
services: Array<{ name: string; count: number; hasError: boolean }>;
|
|
394
493
|
}
|
|
395
494
|
|
|
396
495
|
function TraceSearchView({
|
|
397
|
-
service,
|
|
398
|
-
onBack,
|
|
399
496
|
onSelectTrace,
|
|
497
|
+
onCompare,
|
|
400
498
|
}: {
|
|
401
|
-
service: string;
|
|
402
|
-
onBack: () => void;
|
|
403
499
|
onSelectTrace: (traceId: string) => void;
|
|
500
|
+
onCompare: (traceIds: [string, string]) => void;
|
|
404
501
|
}) {
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
502
|
+
const urlState = useURLState();
|
|
503
|
+
const service = urlState.service;
|
|
504
|
+
|
|
505
|
+
// Build DataSource from URL state
|
|
506
|
+
const ds = useMemo<DataSource>(() => {
|
|
507
|
+
const params: Record<string, unknown> = {
|
|
508
|
+
limit: urlState.limit ?? 20,
|
|
509
|
+
sortOrder: "DESC" as const,
|
|
510
|
+
};
|
|
511
|
+
if (service) params.serviceName = service;
|
|
512
|
+
if (urlState.operation) params.spanName = urlState.operation;
|
|
513
|
+
if (urlState.lookback) {
|
|
514
|
+
const ms = LOOKBACK_MS[urlState.lookback];
|
|
515
|
+
if (ms) {
|
|
516
|
+
params.timestampMin = String((Date.now() - ms) * 1e6);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (urlState.tsMin) params.timestampMin = urlState.tsMin;
|
|
520
|
+
if (urlState.tsMax) params.timestampMax = urlState.tsMax;
|
|
521
|
+
if (urlState.minDuration) {
|
|
522
|
+
const parsed = parseDuration(urlState.minDuration);
|
|
523
|
+
if (parsed) params.durationMin = parsed;
|
|
524
|
+
}
|
|
525
|
+
if (urlState.maxDuration) {
|
|
526
|
+
const parsed = parseDuration(urlState.maxDuration);
|
|
527
|
+
if (parsed) params.durationMax = parsed;
|
|
528
|
+
}
|
|
529
|
+
if (urlState.tags) {
|
|
530
|
+
const tagMap = parseLogfmt(urlState.tags);
|
|
531
|
+
if (Object.keys(tagMap).length > 0) params.tags = tagMap;
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
method: "searchTraceSummariesPage",
|
|
535
|
+
params,
|
|
536
|
+
} as DataSource;
|
|
537
|
+
}, [
|
|
538
|
+
service,
|
|
539
|
+
urlState.operation,
|
|
540
|
+
urlState.lookback,
|
|
541
|
+
urlState.tsMin,
|
|
542
|
+
urlState.tsMax,
|
|
543
|
+
urlState.minDuration,
|
|
544
|
+
urlState.maxDuration,
|
|
545
|
+
urlState.limit,
|
|
546
|
+
urlState.tags,
|
|
547
|
+
]);
|
|
409
548
|
|
|
410
549
|
const handleSearch = useCallback(
|
|
411
550
|
(filters: TraceSearchFilters) => {
|
|
412
|
-
|
|
413
|
-
|
|
551
|
+
pushURLState({
|
|
552
|
+
tab: "services",
|
|
553
|
+
service: filters.service ?? service,
|
|
554
|
+
operation: filters.operation ?? null,
|
|
555
|
+
tags: filters.tags ?? null,
|
|
556
|
+
lookback: filters.lookback ?? null,
|
|
557
|
+
minDuration: filters.minDuration ?? null,
|
|
558
|
+
maxDuration: filters.maxDuration ?? null,
|
|
414
559
|
limit: filters.limit,
|
|
415
|
-
|
|
416
|
-
};
|
|
417
|
-
if (filters.operation) params.spanName = filters.operation;
|
|
418
|
-
if (filters.lookbackMs) {
|
|
419
|
-
params.timestampMin = String((Date.now() - filters.lookbackMs) * 1e6);
|
|
420
|
-
}
|
|
421
|
-
if (filters.minDuration) {
|
|
422
|
-
const parsed = parseDuration(filters.minDuration);
|
|
423
|
-
if (parsed) params.durationMin = parsed;
|
|
424
|
-
}
|
|
425
|
-
if (filters.maxDuration) {
|
|
426
|
-
const parsed = parseDuration(filters.maxDuration);
|
|
427
|
-
if (parsed) params.durationMax = parsed;
|
|
428
|
-
}
|
|
429
|
-
setDs({
|
|
430
|
-
method: "searchTracesPage",
|
|
431
|
-
params,
|
|
432
|
-
} as DataSource);
|
|
560
|
+
});
|
|
433
561
|
},
|
|
434
562
|
[service]
|
|
435
563
|
);
|
|
436
564
|
|
|
565
|
+
// Fetch trace summaries
|
|
437
566
|
const { data, loading, error } = useKopaiData<{
|
|
438
|
-
data:
|
|
567
|
+
data: TraceSummaryRow[];
|
|
439
568
|
nextCursor: string | null;
|
|
440
569
|
}>(ds);
|
|
441
570
|
|
|
442
|
-
// Fetch
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
571
|
+
// Fetch services list
|
|
572
|
+
const serviceDs = useMemo<DataSource>(
|
|
573
|
+
() => ({ method: "getServices" as const }),
|
|
574
|
+
[]
|
|
446
575
|
);
|
|
576
|
+
const { data: servicesData } = useKopaiData<{ services: string[] }>(
|
|
577
|
+
serviceDs
|
|
578
|
+
);
|
|
579
|
+
const _services = servicesData?.services ?? [];
|
|
580
|
+
|
|
581
|
+
// Fetch operations for selected service
|
|
582
|
+
const operationDs = useMemo<DataSource | undefined>(
|
|
583
|
+
() =>
|
|
584
|
+
service
|
|
585
|
+
? { method: "getOperations" as const, params: { serviceName: service } }
|
|
586
|
+
: undefined,
|
|
587
|
+
[service]
|
|
588
|
+
);
|
|
589
|
+
const { data: opsData } = useKopaiData<{ operations: string[] }>(operationDs);
|
|
590
|
+
const operations = opsData?.operations ?? [];
|
|
447
591
|
|
|
448
|
-
|
|
449
|
-
if (!data?.data?.length) {
|
|
450
|
-
setFullTraces(new Map());
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
const traceIds = [...new Set(data.data.map((r) => r.TraceId))];
|
|
454
|
-
const ac = new AbortController();
|
|
455
|
-
|
|
456
|
-
Promise.allSettled(
|
|
457
|
-
traceIds.map((tid) =>
|
|
458
|
-
client
|
|
459
|
-
.getTrace(tid, { signal: ac.signal })
|
|
460
|
-
.then((spans) => [tid, spans] as const)
|
|
461
|
-
)
|
|
462
|
-
)
|
|
463
|
-
.then((results) => {
|
|
464
|
-
if (!ac.signal.aborted) {
|
|
465
|
-
const entries = results
|
|
466
|
-
.filter(
|
|
467
|
-
(
|
|
468
|
-
r
|
|
469
|
-
): r is PromiseFulfilledResult<
|
|
470
|
-
readonly [string, OtelTracesRow[]]
|
|
471
|
-
> => r.status === "fulfilled"
|
|
472
|
-
)
|
|
473
|
-
.map((r) => r.value);
|
|
474
|
-
setFullTraces(new Map(entries));
|
|
475
|
-
}
|
|
476
|
-
})
|
|
477
|
-
.catch((err) => {
|
|
478
|
-
if (!ac.signal.aborted)
|
|
479
|
-
console.error("Failed to fetch full traces", err);
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
return () => ac.abort();
|
|
483
|
-
}, [data, client]);
|
|
484
|
-
|
|
485
|
-
// Derive unique operations for filter dropdown
|
|
486
|
-
const operations = useMemo(() => {
|
|
487
|
-
if (!data?.data) return [];
|
|
488
|
-
const set = new Set<string>();
|
|
489
|
-
for (const row of data.data) {
|
|
490
|
-
if (row.SpanName) set.add(row.SpanName);
|
|
491
|
-
}
|
|
492
|
-
return Array.from(set).sort();
|
|
493
|
-
}, [data]);
|
|
494
|
-
|
|
592
|
+
// Map TraceSummaryRow → TraceSummary
|
|
495
593
|
const traces = useMemo<TraceSummary[]>(() => {
|
|
496
594
|
if (!data?.data) return [];
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
595
|
+
return data.data.map((row) => ({
|
|
596
|
+
traceId: row.traceId,
|
|
597
|
+
rootSpanName: row.rootSpanName,
|
|
598
|
+
serviceName: row.rootServiceName,
|
|
599
|
+
durationMs: parseInt(row.durationNs, 10) / 1e6,
|
|
600
|
+
statusCode: row.errorCount > 0 ? "ERROR" : "OK",
|
|
601
|
+
timestampMs: parseInt(row.startTimeNs, 10) / 1e6,
|
|
602
|
+
spanCount: row.spanCount,
|
|
603
|
+
services: row.services,
|
|
604
|
+
errorCount: row.errorCount,
|
|
605
|
+
}));
|
|
606
|
+
}, [data]);
|
|
503
607
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const spans = fullSpans ?? searchSpans;
|
|
507
|
-
|
|
508
|
-
const root = spans.find((s) => !s.ParentSpanId) ?? spans[0]!;
|
|
509
|
-
const durationNs = root.Duration ? parseInt(root.Duration, 10) : 0;
|
|
510
|
-
|
|
511
|
-
const svcMap = new Map<string, { count: number; hasError: boolean }>();
|
|
512
|
-
let errorCount = 0;
|
|
513
|
-
for (const s of spans) {
|
|
514
|
-
const svcName = s.ServiceName ?? "unknown";
|
|
515
|
-
const entry = svcMap.get(svcName) ?? { count: 0, hasError: false };
|
|
516
|
-
entry.count++;
|
|
517
|
-
if (s.StatusCode === "ERROR") {
|
|
518
|
-
entry.hasError = true;
|
|
519
|
-
errorCount++;
|
|
520
|
-
}
|
|
521
|
-
svcMap.set(svcName, entry);
|
|
522
|
-
}
|
|
523
|
-
const services = Array.from(svcMap.entries())
|
|
524
|
-
.map(([name, v]) => ({ name, count: v.count, hasError: v.hasError }))
|
|
525
|
-
.sort((a, b) => b.count - a.count);
|
|
526
|
-
|
|
527
|
-
return {
|
|
528
|
-
traceId,
|
|
529
|
-
rootSpanName: root.SpanName ?? "unknown",
|
|
530
|
-
serviceName: root.ServiceName ?? "unknown",
|
|
531
|
-
durationMs: durationNs / 1e6,
|
|
532
|
-
statusCode: root.StatusCode ?? "UNSET",
|
|
533
|
-
timestampMs: parseInt(root.Timestamp, 10) / 1e6,
|
|
534
|
-
spanCount: spans.length,
|
|
535
|
-
services,
|
|
536
|
-
errorCount,
|
|
537
|
-
};
|
|
538
|
-
});
|
|
539
|
-
}, [data, fullTraces]);
|
|
608
|
+
// Auto-execute on mount — the ds is already built from URL state,
|
|
609
|
+
// so useKopaiData fires automatically. No extra effect needed.
|
|
540
610
|
|
|
541
611
|
return (
|
|
542
612
|
<TraceSearch
|
|
543
|
-
|
|
613
|
+
services={_services}
|
|
614
|
+
service={service ?? ""}
|
|
544
615
|
traces={traces}
|
|
545
616
|
operations={operations}
|
|
546
617
|
isLoading={loading}
|
|
547
618
|
error={error ?? undefined}
|
|
548
619
|
onSelectTrace={onSelectTrace}
|
|
549
|
-
|
|
620
|
+
onCompare={onCompare}
|
|
550
621
|
onSearch={handleSearch}
|
|
551
622
|
/>
|
|
552
623
|
);
|
|
553
624
|
}
|
|
554
625
|
|
|
555
626
|
function TraceDetailView({
|
|
556
|
-
service,
|
|
557
627
|
traceId,
|
|
558
628
|
selectedSpanId,
|
|
559
629
|
onSelectSpan,
|
|
630
|
+
onDeselectSpan,
|
|
560
631
|
onBack,
|
|
561
632
|
}: {
|
|
562
|
-
service: string;
|
|
563
633
|
traceId: string;
|
|
564
634
|
selectedSpanId: string | null;
|
|
565
635
|
onSelectSpan: (spanId: string) => void;
|
|
636
|
+
onDeselectSpan: () => void;
|
|
566
637
|
onBack: () => void;
|
|
567
638
|
}) {
|
|
568
639
|
const ds = useMemo<DataSource>(
|
|
@@ -577,44 +648,42 @@ function TraceDetailView({
|
|
|
577
648
|
|
|
578
649
|
return (
|
|
579
650
|
<TraceDetail
|
|
580
|
-
service={service}
|
|
581
651
|
traceId={traceId}
|
|
582
652
|
rows={data ?? []}
|
|
583
653
|
isLoading={loading}
|
|
584
654
|
error={error ?? undefined}
|
|
585
655
|
selectedSpanId={selectedSpanId ?? undefined}
|
|
586
656
|
onSpanClick={(span) => onSelectSpan(span.spanId)}
|
|
657
|
+
onSpanDeselect={onDeselectSpan}
|
|
587
658
|
onBack={onBack}
|
|
588
659
|
/>
|
|
589
660
|
);
|
|
590
661
|
}
|
|
591
662
|
|
|
592
663
|
function ServicesTab({
|
|
593
|
-
selectedService,
|
|
594
664
|
selectedTraceId,
|
|
595
665
|
selectedSpanId,
|
|
596
|
-
|
|
666
|
+
compareParam,
|
|
597
667
|
onSelectTrace,
|
|
598
668
|
onSelectSpan,
|
|
599
|
-
|
|
600
|
-
|
|
669
|
+
onDeselectSpan,
|
|
670
|
+
onBack,
|
|
671
|
+
onCompare,
|
|
601
672
|
}: {
|
|
602
|
-
selectedService: string | null;
|
|
603
673
|
selectedTraceId: string | null;
|
|
604
674
|
selectedSpanId: string | null;
|
|
605
|
-
|
|
675
|
+
compareParam: string | null;
|
|
606
676
|
onSelectTrace: (traceId: string) => void;
|
|
607
677
|
onSelectSpan: (spanId: string) => void;
|
|
608
|
-
|
|
609
|
-
|
|
678
|
+
onDeselectSpan: () => void;
|
|
679
|
+
onBack: () => void;
|
|
680
|
+
onCompare: (traceIds: [string, string]) => void;
|
|
610
681
|
}) {
|
|
611
682
|
useRegisterShortcuts("services-tab", SERVICES_SHORTCUTS);
|
|
612
683
|
|
|
613
|
-
// Backspace → navigate back
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
const backToTraceListRef = useRef(onBackToTraceList);
|
|
617
|
-
backToTraceListRef.current = onBackToTraceList;
|
|
684
|
+
// Backspace → navigate back
|
|
685
|
+
const backRef = useRef(onBack);
|
|
686
|
+
backRef.current = onBack;
|
|
618
687
|
useEffect(() => {
|
|
619
688
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
620
689
|
if (
|
|
@@ -625,38 +694,43 @@ function ServicesTab({
|
|
|
625
694
|
return;
|
|
626
695
|
if (e.key === "Backspace") {
|
|
627
696
|
e.preventDefault();
|
|
628
|
-
|
|
629
|
-
backToTraceListRef.current();
|
|
630
|
-
} else if (selectedService) {
|
|
631
|
-
backToServicesRef.current();
|
|
632
|
-
}
|
|
697
|
+
backRef.current();
|
|
633
698
|
}
|
|
634
699
|
};
|
|
635
700
|
window.addEventListener("keydown", handleKeyDown);
|
|
636
701
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
637
|
-
}, [
|
|
702
|
+
}, []);
|
|
703
|
+
|
|
704
|
+
// Comparison view
|
|
705
|
+
if (compareParam) {
|
|
706
|
+
const [traceIdA, traceIdB] = compareParam.split(",");
|
|
707
|
+
if (traceIdA && traceIdB) {
|
|
708
|
+
return (
|
|
709
|
+
<TraceComparison
|
|
710
|
+
traceIdA={traceIdA}
|
|
711
|
+
traceIdB={traceIdB}
|
|
712
|
+
onBack={onBack}
|
|
713
|
+
/>
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
638
717
|
|
|
639
|
-
if (selectedTraceId
|
|
718
|
+
if (selectedTraceId) {
|
|
640
719
|
return (
|
|
641
720
|
<TraceDetailView
|
|
642
|
-
service={selectedService}
|
|
643
721
|
traceId={selectedTraceId}
|
|
644
722
|
selectedSpanId={selectedSpanId}
|
|
645
723
|
onSelectSpan={onSelectSpan}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
if (selectedService) {
|
|
651
|
-
return (
|
|
652
|
-
<TraceSearchView
|
|
653
|
-
service={selectedService}
|
|
654
|
-
onBack={onBackToServices}
|
|
655
|
-
onSelectTrace={onSelectTrace}
|
|
724
|
+
onDeselectSpan={onDeselectSpan}
|
|
725
|
+
onBack={onBack}
|
|
656
726
|
/>
|
|
657
727
|
);
|
|
658
728
|
}
|
|
659
|
-
|
|
729
|
+
|
|
730
|
+
// Default: TraceSearchView directly
|
|
731
|
+
return (
|
|
732
|
+
<TraceSearchView onSelectTrace={onSelectTrace} onCompare={onCompare} />
|
|
733
|
+
);
|
|
660
734
|
}
|
|
661
735
|
|
|
662
736
|
// ---------------------------------------------------------------------------
|
|
@@ -669,7 +743,14 @@ const METRICS_TREE = {
|
|
|
669
743
|
root: {
|
|
670
744
|
key: "root",
|
|
671
745
|
type: "Stack" as const,
|
|
672
|
-
children: [
|
|
746
|
+
children: [
|
|
747
|
+
"heading",
|
|
748
|
+
"ingestion-heading",
|
|
749
|
+
"ingestion-grid",
|
|
750
|
+
"discovery-heading",
|
|
751
|
+
"description",
|
|
752
|
+
"discovery-card",
|
|
753
|
+
],
|
|
673
754
|
parentKey: "",
|
|
674
755
|
props: {
|
|
675
756
|
direction: "vertical" as const,
|
|
@@ -684,6 +765,81 @@ const METRICS_TREE = {
|
|
|
684
765
|
parentKey: "root",
|
|
685
766
|
props: { text: "Metrics", level: "h2" as const },
|
|
686
767
|
},
|
|
768
|
+
"ingestion-heading": {
|
|
769
|
+
key: "ingestion-heading",
|
|
770
|
+
type: "Heading" as const,
|
|
771
|
+
children: [],
|
|
772
|
+
parentKey: "root",
|
|
773
|
+
props: { text: "OTEL Ingestion", level: "h3" as const },
|
|
774
|
+
},
|
|
775
|
+
"ingestion-grid": {
|
|
776
|
+
key: "ingestion-grid",
|
|
777
|
+
type: "Grid" as const,
|
|
778
|
+
children: ["card-bytes", "card-requests"],
|
|
779
|
+
parentKey: "root",
|
|
780
|
+
props: { columns: 2, gap: "md" as const },
|
|
781
|
+
},
|
|
782
|
+
"card-bytes": {
|
|
783
|
+
key: "card-bytes",
|
|
784
|
+
type: "Card" as const,
|
|
785
|
+
children: ["stat-bytes"],
|
|
786
|
+
parentKey: "ingestion-grid",
|
|
787
|
+
props: {
|
|
788
|
+
title: "Total Bytes Ingested",
|
|
789
|
+
description: null,
|
|
790
|
+
padding: null,
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
"stat-bytes": {
|
|
794
|
+
key: "stat-bytes",
|
|
795
|
+
type: "MetricStat" as const,
|
|
796
|
+
children: [],
|
|
797
|
+
parentKey: "card-bytes",
|
|
798
|
+
dataSource: {
|
|
799
|
+
method: "searchMetricsPage" as const,
|
|
800
|
+
params: {
|
|
801
|
+
metricType: "Sum" as const,
|
|
802
|
+
metricName: "kopai.ingestion.bytes",
|
|
803
|
+
aggregate: "sum" as const,
|
|
804
|
+
},
|
|
805
|
+
refetchIntervalMs: 10_000,
|
|
806
|
+
},
|
|
807
|
+
props: { label: "Bytes", showSparkline: false },
|
|
808
|
+
},
|
|
809
|
+
"card-requests": {
|
|
810
|
+
key: "card-requests",
|
|
811
|
+
type: "Card" as const,
|
|
812
|
+
children: ["stat-requests"],
|
|
813
|
+
parentKey: "ingestion-grid",
|
|
814
|
+
props: {
|
|
815
|
+
title: "Total Requests",
|
|
816
|
+
description: null,
|
|
817
|
+
padding: null,
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
"stat-requests": {
|
|
821
|
+
key: "stat-requests",
|
|
822
|
+
type: "MetricStat" as const,
|
|
823
|
+
children: [],
|
|
824
|
+
parentKey: "card-requests",
|
|
825
|
+
dataSource: {
|
|
826
|
+
method: "searchMetricsPage" as const,
|
|
827
|
+
params: {
|
|
828
|
+
metricType: "Sum" as const,
|
|
829
|
+
metricName: "kopai.ingestion.requests",
|
|
830
|
+
aggregate: "sum" as const,
|
|
831
|
+
},
|
|
832
|
+
refetchIntervalMs: 10_000,
|
|
833
|
+
},
|
|
834
|
+
props: { label: "Requests", showSparkline: false },
|
|
835
|
+
},
|
|
836
|
+
"discovery-heading": {
|
|
837
|
+
key: "discovery-heading",
|
|
838
|
+
type: "Heading" as const,
|
|
839
|
+
children: [],
|
|
840
|
+
parentKey: "root",
|
|
841
|
+
props: { text: "Discovered Metrics", level: "h3" as const },
|
|
842
|
+
},
|
|
687
843
|
description: {
|
|
688
844
|
key: "description",
|
|
689
845
|
type: "Text" as const,
|
|
@@ -778,52 +934,57 @@ export default function ObservabilityPage({ client }: ObservabilityPageProps) {
|
|
|
778
934
|
const activeClient = client ?? getDefaultClient();
|
|
779
935
|
const {
|
|
780
936
|
tab: activeTab,
|
|
781
|
-
service: selectedService,
|
|
782
937
|
trace: selectedTraceId,
|
|
783
938
|
span: selectedSpanId,
|
|
939
|
+
compare: compareParam,
|
|
784
940
|
} = useURLState();
|
|
785
941
|
|
|
786
942
|
const handleTabChange = useCallback((tab: Tab) => {
|
|
787
943
|
pushURLState({ tab });
|
|
788
944
|
}, []);
|
|
789
945
|
|
|
790
|
-
const
|
|
791
|
-
pushURLState({ tab: "services",
|
|
946
|
+
const handleSelectTrace = useCallback((traceId: string) => {
|
|
947
|
+
pushURLState({ ...readURLState(), tab: "services", trace: traceId });
|
|
792
948
|
}, []);
|
|
793
949
|
|
|
794
|
-
const
|
|
795
|
-
(
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
});
|
|
801
|
-
},
|
|
802
|
-
[selectedService]
|
|
803
|
-
);
|
|
950
|
+
const handleSelectSpan = useCallback((spanId: string) => {
|
|
951
|
+
pushURLState(
|
|
952
|
+
{ ...readURLState(), tab: "services", span: spanId },
|
|
953
|
+
{ replace: true }
|
|
954
|
+
);
|
|
955
|
+
}, []);
|
|
804
956
|
|
|
805
|
-
const
|
|
806
|
-
(
|
|
807
|
-
|
|
808
|
-
{
|
|
809
|
-
tab: "services",
|
|
810
|
-
service: selectedService,
|
|
811
|
-
trace: selectedTraceId,
|
|
812
|
-
span: spanId,
|
|
813
|
-
},
|
|
814
|
-
{ replace: true }
|
|
815
|
-
);
|
|
816
|
-
},
|
|
817
|
-
[selectedService, selectedTraceId]
|
|
818
|
-
);
|
|
957
|
+
const handleDeselectSpan = useCallback(() => {
|
|
958
|
+
pushURLState({ ...readURLState(), span: null }, { replace: true });
|
|
959
|
+
}, []);
|
|
819
960
|
|
|
820
|
-
const
|
|
821
|
-
pushURLState({
|
|
961
|
+
const handleCompare = useCallback((traceIds: [string, string]) => {
|
|
962
|
+
pushURLState({
|
|
963
|
+
...readURLState(),
|
|
964
|
+
tab: "services",
|
|
965
|
+
trace: null,
|
|
966
|
+
span: null,
|
|
967
|
+
view: null,
|
|
968
|
+
uiFind: null,
|
|
969
|
+
viewStart: null,
|
|
970
|
+
viewEnd: null,
|
|
971
|
+
compare: traceIds.join(","),
|
|
972
|
+
});
|
|
822
973
|
}, []);
|
|
823
974
|
|
|
824
|
-
const
|
|
825
|
-
pushURLState({
|
|
826
|
-
|
|
975
|
+
const handleBack = useCallback(() => {
|
|
976
|
+
pushURLState({
|
|
977
|
+
...readURLState(),
|
|
978
|
+
tab: "services",
|
|
979
|
+
trace: null,
|
|
980
|
+
span: null,
|
|
981
|
+
view: null,
|
|
982
|
+
uiFind: null,
|
|
983
|
+
viewStart: null,
|
|
984
|
+
viewEnd: null,
|
|
985
|
+
compare: null,
|
|
986
|
+
});
|
|
987
|
+
}, []);
|
|
827
988
|
|
|
828
989
|
return (
|
|
829
990
|
<KopaiSDKProvider client={activeClient}>
|
|
@@ -841,14 +1002,14 @@ export default function ObservabilityPage({ client }: ObservabilityPageProps) {
|
|
|
841
1002
|
{activeTab === "logs" && <LogsTab />}
|
|
842
1003
|
{activeTab === "services" && (
|
|
843
1004
|
<ServicesTab
|
|
844
|
-
selectedService={selectedService}
|
|
845
1005
|
selectedTraceId={selectedTraceId}
|
|
846
1006
|
selectedSpanId={selectedSpanId}
|
|
847
|
-
|
|
1007
|
+
compareParam={compareParam}
|
|
848
1008
|
onSelectTrace={handleSelectTrace}
|
|
849
1009
|
onSelectSpan={handleSelectSpan}
|
|
850
|
-
|
|
851
|
-
|
|
1010
|
+
onDeselectSpan={handleDeselectSpan}
|
|
1011
|
+
onBack={handleBack}
|
|
1012
|
+
onCompare={handleCompare}
|
|
852
1013
|
/>
|
|
853
1014
|
)}
|
|
854
1015
|
{activeTab === "metrics" && <MetricsTab />}
|