@kopai/ui 0.7.0 → 0.9.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 +2451 -1157
- package/dist/index.d.cts +36 -7
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +36 -7
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2399 -1099
- 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 +5 -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/MetricTimeSeries/index.tsx +25 -14
- 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/OtelLogTimeline.tsx +9 -5
- 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 +4 -0
- package/src/hooks/use-kopai-data.ts +11 -0
- 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/pages/observability.test.tsx +16 -12
- package/src/pages/observability.tsx +323 -245
- 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,46 +694,49 @@ 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
|
+
}, []);
|
|
638
703
|
|
|
639
|
-
|
|
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
|
+
}
|
|
717
|
+
|
|
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
|
-
|
|
724
|
+
onDeselectSpan={onDeselectSpan}
|
|
725
|
+
onBack={onBack}
|
|
647
726
|
/>
|
|
648
727
|
);
|
|
649
728
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
onSelectTrace={onSelectTrace}
|
|
656
|
-
/>
|
|
657
|
-
);
|
|
658
|
-
}
|
|
659
|
-
return <ServiceListView onSelect={onSelectService} />;
|
|
729
|
+
|
|
730
|
+
// Default: TraceSearchView directly
|
|
731
|
+
return (
|
|
732
|
+
<TraceSearchView onSelectTrace={onSelectTrace} onCompare={onCompare} />
|
|
733
|
+
);
|
|
660
734
|
}
|
|
661
735
|
|
|
662
736
|
// ---------------------------------------------------------------------------
|
|
663
737
|
// Metrics tab — DynamicDashboard
|
|
664
738
|
// ---------------------------------------------------------------------------
|
|
665
739
|
|
|
666
|
-
const DASHBOARDS_API_BASE = "/dashboards";
|
|
667
|
-
|
|
668
740
|
const METRICS_TREE = {
|
|
669
741
|
root: "root",
|
|
670
742
|
elements: {
|
|
@@ -715,16 +787,17 @@ const METRICS_TREE = {
|
|
|
715
787
|
},
|
|
716
788
|
};
|
|
717
789
|
|
|
718
|
-
function useDashboardTree(
|
|
790
|
+
function useDashboardTree(
|
|
791
|
+
client: Pick<KopaiClient, "getDashboard">,
|
|
792
|
+
dashboardId: string | null
|
|
793
|
+
) {
|
|
719
794
|
const { data, isFetching, error } = useQuery<UITree, Error>({
|
|
720
795
|
queryKey: ["dashboard-tree", dashboardId],
|
|
721
796
|
queryFn: async ({ signal }) => {
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const json = await res.json();
|
|
727
|
-
const parsed = observabilityCatalog.uiTreeSchema.safeParse(json.uiTree);
|
|
797
|
+
const dashboard = await client.getDashboard(dashboardId!, { signal });
|
|
798
|
+
const parsed = observabilityCatalog.uiTreeSchema.safeParse(
|
|
799
|
+
dashboard.uiTree
|
|
800
|
+
);
|
|
728
801
|
if (!parsed.success) {
|
|
729
802
|
const issue = parsed.error.issues[0];
|
|
730
803
|
const path = issue?.path.length ? issue.path.join(".") + ": " : "";
|
|
@@ -747,7 +820,7 @@ function useDashboardTree(dashboardId: string | null) {
|
|
|
747
820
|
function MetricsTab() {
|
|
748
821
|
const kopaiClient = useKopaiSDK();
|
|
749
822
|
const { dashboardId } = useURLState();
|
|
750
|
-
const { loading, error, tree } = useDashboardTree(dashboardId);
|
|
823
|
+
const { loading, error, tree } = useDashboardTree(kopaiClient, dashboardId);
|
|
751
824
|
|
|
752
825
|
if (loading)
|
|
753
826
|
return (
|
|
@@ -779,52 +852,57 @@ export default function ObservabilityPage({ client }: ObservabilityPageProps) {
|
|
|
779
852
|
const activeClient = client ?? getDefaultClient();
|
|
780
853
|
const {
|
|
781
854
|
tab: activeTab,
|
|
782
|
-
service: selectedService,
|
|
783
855
|
trace: selectedTraceId,
|
|
784
856
|
span: selectedSpanId,
|
|
857
|
+
compare: compareParam,
|
|
785
858
|
} = useURLState();
|
|
786
859
|
|
|
787
860
|
const handleTabChange = useCallback((tab: Tab) => {
|
|
788
861
|
pushURLState({ tab });
|
|
789
862
|
}, []);
|
|
790
863
|
|
|
791
|
-
const
|
|
792
|
-
pushURLState({ tab: "services",
|
|
864
|
+
const handleSelectTrace = useCallback((traceId: string) => {
|
|
865
|
+
pushURLState({ ...readURLState(), tab: "services", trace: traceId });
|
|
793
866
|
}, []);
|
|
794
867
|
|
|
795
|
-
const
|
|
796
|
-
(
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
});
|
|
802
|
-
},
|
|
803
|
-
[selectedService]
|
|
804
|
-
);
|
|
868
|
+
const handleSelectSpan = useCallback((spanId: string) => {
|
|
869
|
+
pushURLState(
|
|
870
|
+
{ ...readURLState(), tab: "services", span: spanId },
|
|
871
|
+
{ replace: true }
|
|
872
|
+
);
|
|
873
|
+
}, []);
|
|
805
874
|
|
|
806
|
-
const
|
|
807
|
-
(
|
|
808
|
-
|
|
809
|
-
{
|
|
810
|
-
tab: "services",
|
|
811
|
-
service: selectedService,
|
|
812
|
-
trace: selectedTraceId,
|
|
813
|
-
span: spanId,
|
|
814
|
-
},
|
|
815
|
-
{ replace: true }
|
|
816
|
-
);
|
|
817
|
-
},
|
|
818
|
-
[selectedService, selectedTraceId]
|
|
819
|
-
);
|
|
875
|
+
const handleDeselectSpan = useCallback(() => {
|
|
876
|
+
pushURLState({ ...readURLState(), span: null }, { replace: true });
|
|
877
|
+
}, []);
|
|
820
878
|
|
|
821
|
-
const
|
|
822
|
-
pushURLState({
|
|
879
|
+
const handleCompare = useCallback((traceIds: [string, string]) => {
|
|
880
|
+
pushURLState({
|
|
881
|
+
...readURLState(),
|
|
882
|
+
tab: "services",
|
|
883
|
+
trace: null,
|
|
884
|
+
span: null,
|
|
885
|
+
view: null,
|
|
886
|
+
uiFind: null,
|
|
887
|
+
viewStart: null,
|
|
888
|
+
viewEnd: null,
|
|
889
|
+
compare: traceIds.join(","),
|
|
890
|
+
});
|
|
823
891
|
}, []);
|
|
824
892
|
|
|
825
|
-
const
|
|
826
|
-
pushURLState({
|
|
827
|
-
|
|
893
|
+
const handleBack = useCallback(() => {
|
|
894
|
+
pushURLState({
|
|
895
|
+
...readURLState(),
|
|
896
|
+
tab: "services",
|
|
897
|
+
trace: null,
|
|
898
|
+
span: null,
|
|
899
|
+
view: null,
|
|
900
|
+
uiFind: null,
|
|
901
|
+
viewStart: null,
|
|
902
|
+
viewEnd: null,
|
|
903
|
+
compare: null,
|
|
904
|
+
});
|
|
905
|
+
}, []);
|
|
828
906
|
|
|
829
907
|
return (
|
|
830
908
|
<KopaiSDKProvider client={activeClient}>
|
|
@@ -842,14 +920,14 @@ export default function ObservabilityPage({ client }: ObservabilityPageProps) {
|
|
|
842
920
|
{activeTab === "logs" && <LogsTab />}
|
|
843
921
|
{activeTab === "services" && (
|
|
844
922
|
<ServicesTab
|
|
845
|
-
selectedService={selectedService}
|
|
846
923
|
selectedTraceId={selectedTraceId}
|
|
847
924
|
selectedSpanId={selectedSpanId}
|
|
848
|
-
|
|
925
|
+
compareParam={compareParam}
|
|
849
926
|
onSelectTrace={handleSelectTrace}
|
|
850
927
|
onSelectSpan={handleSelectSpan}
|
|
851
|
-
|
|
852
|
-
|
|
928
|
+
onDeselectSpan={handleDeselectSpan}
|
|
929
|
+
onBack={handleBack}
|
|
930
|
+
onCompare={handleCompare}
|
|
853
931
|
/>
|
|
854
932
|
)}
|
|
855
933
|
{activeTab === "metrics" && <MetricsTab />}
|