@kopai/ui 0.10.0 → 0.11.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 +56 -5
- package/dist/index.d.cts +5 -6
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +5 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +56 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -12
- package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +172 -1
- package/src/components/observability/renderers/OtelTraceDetail.tsx +88 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kopai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Vladimir Adamic",
|
|
6
6
|
"repository": {
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@tanstack/react-query": "^5",
|
|
34
|
-
"@tanstack/react-virtual": "^3.13.
|
|
35
|
-
"recharts": "^3.8.
|
|
34
|
+
"@tanstack/react-virtual": "^3.13.23",
|
|
35
|
+
"recharts": "^3.8.1",
|
|
36
36
|
"@kopai/core": "0.9.0",
|
|
37
37
|
"@kopai/sdk": "0.7.0"
|
|
38
38
|
},
|
|
@@ -41,22 +41,22 @@
|
|
|
41
41
|
"react-dom": "^19.2.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@storybook/addon-docs": "^10.
|
|
45
|
-
"@storybook/react": "^10.
|
|
46
|
-
"@storybook/react-vite": "^10.
|
|
44
|
+
"@storybook/addon-docs": "^10.3.4",
|
|
45
|
+
"@storybook/react": "^10.3.4",
|
|
46
|
+
"@storybook/react-vite": "^10.3.4",
|
|
47
47
|
"@testing-library/react": "^16.3.0",
|
|
48
48
|
"@types/react": "^19.2.14",
|
|
49
49
|
"@types/react-dom": "^19.1.0",
|
|
50
50
|
"@vitejs/plugin-react": "^6.0.1",
|
|
51
|
-
"@tailwindcss/postcss": "^4.2.
|
|
52
|
-
"jsdom": "^
|
|
51
|
+
"@tailwindcss/postcss": "^4.2.2",
|
|
52
|
+
"jsdom": "^29.0.1",
|
|
53
53
|
"postcss": "^8.5.8",
|
|
54
54
|
"react": "^19.2.4",
|
|
55
55
|
"react-dom": "^19.2.4",
|
|
56
|
-
"storybook": "^10.
|
|
57
|
-
"tailwindcss": "^4.2.
|
|
58
|
-
"tsdown": "^0.21.
|
|
59
|
-
"vite": "^8.0.
|
|
56
|
+
"storybook": "^10.3.4",
|
|
57
|
+
"tailwindcss": "^4.2.2",
|
|
58
|
+
"tsdown": "^0.21.7",
|
|
59
|
+
"vite": "^8.0.3",
|
|
60
60
|
"@kopai/tsconfig": "0.2.0"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
5
5
|
import { createElement } from "react";
|
|
6
|
-
import { render, waitFor } from "@testing-library/react";
|
|
6
|
+
import { render, waitFor, fireEvent } from "@testing-library/react";
|
|
7
7
|
import { DynamicDashboard, type UITree } from "./index.js";
|
|
8
8
|
import { queryClient } from "../../../providers/kopai-provider.js";
|
|
9
9
|
import type { KopaiClient } from "@kopai/sdk";
|
|
@@ -222,6 +222,177 @@ describe("DynamicDashboard", () => {
|
|
|
222
222
|
vi.clearAllMocks();
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
it("renders TraceDetail with searchTraceSummariesPage without crashing", async () => {
|
|
226
|
+
const summaryTree = {
|
|
227
|
+
root: "root",
|
|
228
|
+
elements: {
|
|
229
|
+
root: {
|
|
230
|
+
key: "root",
|
|
231
|
+
type: "Stack" as const,
|
|
232
|
+
children: ["trace-detail"],
|
|
233
|
+
parentKey: "",
|
|
234
|
+
props: {
|
|
235
|
+
direction: "vertical" as const,
|
|
236
|
+
gap: "md" as const,
|
|
237
|
+
align: null,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
"trace-detail": {
|
|
241
|
+
key: "trace-detail",
|
|
242
|
+
type: "TraceDetail" as const,
|
|
243
|
+
children: [],
|
|
244
|
+
parentKey: "root",
|
|
245
|
+
props: { height: 400 },
|
|
246
|
+
dataSource: {
|
|
247
|
+
method: "searchTraceSummariesPage" as const,
|
|
248
|
+
params: {
|
|
249
|
+
serviceName: "test-service",
|
|
250
|
+
limit: 20,
|
|
251
|
+
sortOrder: "DESC" as const,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
} satisfies UITree;
|
|
257
|
+
|
|
258
|
+
mockClient.searchTraceSummariesPage.mockResolvedValue({
|
|
259
|
+
data: [
|
|
260
|
+
{
|
|
261
|
+
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
262
|
+
rootServiceName: "api-gateway",
|
|
263
|
+
rootSpanName: "GET /api/users",
|
|
264
|
+
startTimeNs: "1700000000000000000",
|
|
265
|
+
durationNs: "320000000",
|
|
266
|
+
spanCount: 8,
|
|
267
|
+
errorCount: 0,
|
|
268
|
+
services: [{ name: "api-gateway", count: 3, hasError: false }],
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
nextCursor: null,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const { container } = render(
|
|
275
|
+
createElement(DynamicDashboard, {
|
|
276
|
+
kopaiClient: mockClient as unknown as KopaiClient,
|
|
277
|
+
uiTree: summaryTree,
|
|
278
|
+
})
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
await waitFor(() => {
|
|
282
|
+
expect(mockClient.searchTraceSummariesPage).toHaveBeenCalled();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Should render trace summary list without crashing
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(container.textContent).toContain("GET /api/users");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("drills down from trace summary to trace detail on click", async () => {
|
|
292
|
+
const TRACE_ID = "0af7651916cd43dd8448eb211c80319c";
|
|
293
|
+
|
|
294
|
+
const summaryTree = {
|
|
295
|
+
root: "root",
|
|
296
|
+
elements: {
|
|
297
|
+
root: {
|
|
298
|
+
key: "root",
|
|
299
|
+
type: "Stack" as const,
|
|
300
|
+
children: ["trace-detail"],
|
|
301
|
+
parentKey: "",
|
|
302
|
+
props: {
|
|
303
|
+
direction: "vertical" as const,
|
|
304
|
+
gap: "md" as const,
|
|
305
|
+
align: null,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
"trace-detail": {
|
|
309
|
+
key: "trace-detail",
|
|
310
|
+
type: "TraceDetail" as const,
|
|
311
|
+
children: [],
|
|
312
|
+
parentKey: "root",
|
|
313
|
+
props: { height: 400 },
|
|
314
|
+
dataSource: {
|
|
315
|
+
method: "searchTraceSummariesPage" as const,
|
|
316
|
+
params: {
|
|
317
|
+
serviceName: "test-service",
|
|
318
|
+
limit: 20,
|
|
319
|
+
sortOrder: "DESC" as const,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
} satisfies UITree;
|
|
325
|
+
|
|
326
|
+
mockClient.searchTraceSummariesPage.mockResolvedValue({
|
|
327
|
+
data: [
|
|
328
|
+
{
|
|
329
|
+
traceId: TRACE_ID,
|
|
330
|
+
rootServiceName: "api-gateway",
|
|
331
|
+
rootSpanName: "GET /api/users",
|
|
332
|
+
startTimeNs: "1700000000000000000",
|
|
333
|
+
durationNs: "320000000",
|
|
334
|
+
spanCount: 8,
|
|
335
|
+
errorCount: 0,
|
|
336
|
+
services: [{ name: "api-gateway", count: 3, hasError: false }],
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
nextCursor: null,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Mock getTrace to return span data for drill-down
|
|
343
|
+
mockClient.getTrace.mockResolvedValue([
|
|
344
|
+
{
|
|
345
|
+
SpanId: "b7ad6b7169203331",
|
|
346
|
+
TraceId: TRACE_ID,
|
|
347
|
+
Timestamp: "1700000000000000000",
|
|
348
|
+
Duration: "320000000",
|
|
349
|
+
ParentSpanId: "",
|
|
350
|
+
ServiceName: "api-gateway",
|
|
351
|
+
SpanName: "GET /api/users",
|
|
352
|
+
SpanKind: "SERVER",
|
|
353
|
+
StatusCode: "OK",
|
|
354
|
+
StatusMessage: "",
|
|
355
|
+
ScopeName: "",
|
|
356
|
+
ScopeVersion: "",
|
|
357
|
+
SpanAttributes: {},
|
|
358
|
+
ResourceAttributes: { "service.name": "api-gateway" },
|
|
359
|
+
"Events.Name": [],
|
|
360
|
+
"Events.Timestamp": [],
|
|
361
|
+
"Events.Attributes": [],
|
|
362
|
+
},
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
const { container, getByText } = render(
|
|
366
|
+
createElement(DynamicDashboard, {
|
|
367
|
+
kopaiClient: mockClient as unknown as KopaiClient,
|
|
368
|
+
uiTree: summaryTree,
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// Wait for summaries to render
|
|
373
|
+
await waitFor(() => {
|
|
374
|
+
expect(container.textContent).toContain("GET /api/users");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Click the trace row to drill down
|
|
378
|
+
const traceRow = getByText("api-gateway: GET /api/users");
|
|
379
|
+
fireEvent.click(traceRow);
|
|
380
|
+
|
|
381
|
+
// Should fetch the full trace
|
|
382
|
+
await waitFor(() => {
|
|
383
|
+
expect(mockClient.getTrace).toHaveBeenCalledWith(
|
|
384
|
+
TRACE_ID,
|
|
385
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Should render the trace detail view with "Traces" breadcrumb
|
|
390
|
+
await waitFor(() => {
|
|
391
|
+
expect(container.textContent).toContain("Traces");
|
|
392
|
+
expect(container.textContent).toContain(TRACE_ID.slice(0, 16));
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
225
396
|
it("renders a UITree containing all catalog components", async () => {
|
|
226
397
|
const { container } = render(
|
|
227
398
|
createElement(DynamicDashboard, {
|
|
@@ -1,14 +1,90 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
1
3
|
import { observabilityCatalog } from "../../../lib/observability-catalog.js";
|
|
2
4
|
import type { RendererComponentProps } from "../../../lib/renderer.js";
|
|
3
5
|
import { TraceDetail } from "../index.js";
|
|
4
|
-
import
|
|
6
|
+
import { TraceSearch } from "../TraceSearch/index.js";
|
|
7
|
+
import type { TraceSummary } from "../TraceSearch/index.js";
|
|
8
|
+
import { useKopaiSDK } from "../../../providers/kopai-provider.js";
|
|
9
|
+
import type { denormalizedSignals, dataFilterSchemas } from "@kopai/core";
|
|
5
10
|
|
|
6
11
|
type OtelTracesRow = denormalizedSignals.OtelTracesRow;
|
|
12
|
+
type TraceSummaryRow = dataFilterSchemas.TraceSummaryRow;
|
|
7
13
|
|
|
8
14
|
type Props = RendererComponentProps<
|
|
9
15
|
typeof observabilityCatalog.components.TraceDetail
|
|
10
16
|
>;
|
|
11
17
|
|
|
18
|
+
function isTraceSummariesSource(props: Props & { hasData: true }): boolean {
|
|
19
|
+
return props.element.dataSource?.method === "searchTraceSummariesPage";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function TraceSummariesView({
|
|
23
|
+
data,
|
|
24
|
+
loading,
|
|
25
|
+
error,
|
|
26
|
+
}: {
|
|
27
|
+
data: unknown;
|
|
28
|
+
loading: boolean;
|
|
29
|
+
error: Error | null;
|
|
30
|
+
}) {
|
|
31
|
+
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
|
|
32
|
+
const client = useKopaiSDK();
|
|
33
|
+
|
|
34
|
+
const response = data as { data?: TraceSummaryRow[] } | null;
|
|
35
|
+
|
|
36
|
+
const traces = useMemo<TraceSummary[]>(() => {
|
|
37
|
+
const rows = response?.data;
|
|
38
|
+
if (!Array.isArray(rows)) return [];
|
|
39
|
+
return rows.map((row) => ({
|
|
40
|
+
traceId: row.traceId,
|
|
41
|
+
rootSpanName: row.rootSpanName,
|
|
42
|
+
serviceName: row.rootServiceName,
|
|
43
|
+
durationMs: parseInt(row.durationNs, 10) / 1e6,
|
|
44
|
+
statusCode: row.errorCount > 0 ? "ERROR" : "OK",
|
|
45
|
+
timestampMs: parseInt(row.startTimeNs, 10) / 1e6,
|
|
46
|
+
spanCount: row.spanCount,
|
|
47
|
+
services: row.services,
|
|
48
|
+
errorCount: row.errorCount,
|
|
49
|
+
}));
|
|
50
|
+
}, [response]);
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
data: traceRows,
|
|
54
|
+
isFetching: traceLoading,
|
|
55
|
+
error: traceError,
|
|
56
|
+
} = useQuery<OtelTracesRow[], Error>({
|
|
57
|
+
queryKey: ["kopai", "getTrace", selectedTraceId],
|
|
58
|
+
queryFn: ({ signal }) => client.getTrace(selectedTraceId!, { signal }),
|
|
59
|
+
enabled: !!selectedTraceId,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const handleBack = useCallback(() => setSelectedTraceId(null), []);
|
|
63
|
+
|
|
64
|
+
if (selectedTraceId) {
|
|
65
|
+
return (
|
|
66
|
+
<TraceDetail
|
|
67
|
+
traceId={selectedTraceId}
|
|
68
|
+
rows={traceRows ?? []}
|
|
69
|
+
isLoading={traceLoading}
|
|
70
|
+
error={traceError ?? undefined}
|
|
71
|
+
onBack={handleBack}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<TraceSearch
|
|
78
|
+
services={[]}
|
|
79
|
+
service=""
|
|
80
|
+
traces={traces}
|
|
81
|
+
isLoading={loading}
|
|
82
|
+
error={error ?? undefined}
|
|
83
|
+
onSelectTrace={setSelectedTraceId}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
12
88
|
export function OtelTraceDetail(props: Props) {
|
|
13
89
|
if (!props.hasData) {
|
|
14
90
|
return (
|
|
@@ -16,8 +92,18 @@ export function OtelTraceDetail(props: Props) {
|
|
|
16
92
|
);
|
|
17
93
|
}
|
|
18
94
|
|
|
95
|
+
if (isTraceSummariesSource(props)) {
|
|
96
|
+
return (
|
|
97
|
+
<TraceSummariesView
|
|
98
|
+
data={props.data}
|
|
99
|
+
loading={props.loading}
|
|
100
|
+
error={props.error}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
19
105
|
const response = props.data as { data?: OtelTracesRow[] } | null;
|
|
20
|
-
const rows = response?.data
|
|
106
|
+
const rows = Array.isArray(response?.data) ? response.data : [];
|
|
21
107
|
const traceId = rows[0]?.TraceId ?? "";
|
|
22
108
|
|
|
23
109
|
return (
|