@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kopai/ui",
3
- "version": "0.10.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.22",
35
- "recharts": "^3.8.0",
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.2.19",
45
- "@storybook/react": "^10.2.19",
46
- "@storybook/react-vite": "^10.2.19",
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.1",
52
- "jsdom": "^28.1.0",
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.2.19",
57
- "tailwindcss": "^4.2.1",
58
- "tsdown": "^0.21.2",
59
- "vite": "^8.0.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 type { denormalizedSignals } from "@kopai/core";
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 (