@kopai/ui 0.0.4 → 0.1.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.
Files changed (125) hide show
  1. package/README.md +137 -0
  2. package/dist/index.cjs +5069 -3
  3. package/dist/index.d.cts +301 -3
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +302 -3
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +5010 -3
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +30 -12
  10. package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +113 -0
  11. package/src/components/KeyboardShortcuts/ShortcutsHelpDialog.tsx +82 -0
  12. package/src/components/KeyboardShortcuts/context.ts +23 -0
  13. package/src/components/KeyboardShortcuts/index.ts +8 -0
  14. package/src/components/KeyboardShortcuts/types.ts +11 -0
  15. package/src/components/dashboard/Badge/Badge.stories.tsx +29 -0
  16. package/src/components/dashboard/Badge/index.tsx +32 -0
  17. package/src/components/dashboard/Button/Button.stories.tsx +107 -0
  18. package/src/components/dashboard/Button/index.tsx +63 -0
  19. package/src/components/dashboard/Card/Card.stories.tsx +81 -0
  20. package/src/components/dashboard/Card/index.tsx +58 -0
  21. package/src/components/dashboard/Chart/Chart.stories.tsx +48 -0
  22. package/src/components/dashboard/Chart/index.tsx +74 -0
  23. package/src/components/dashboard/DatePicker/DatePicker.stories.tsx +33 -0
  24. package/src/components/dashboard/DatePicker/index.tsx +41 -0
  25. package/src/components/dashboard/Divider/Divider.stories.tsx +17 -0
  26. package/src/components/dashboard/Divider/index.tsx +49 -0
  27. package/src/components/dashboard/Empty/Empty.stories.tsx +48 -0
  28. package/src/components/dashboard/Empty/index.tsx +46 -0
  29. package/src/components/dashboard/Grid/Grid.stories.tsx +52 -0
  30. package/src/components/dashboard/Grid/index.tsx +26 -0
  31. package/src/components/dashboard/Heading/Heading.stories.tsx +25 -0
  32. package/src/components/dashboard/Heading/index.tsx +27 -0
  33. package/src/components/dashboard/List/List.stories.tsx +37 -0
  34. package/src/components/dashboard/List/index.tsx +24 -0
  35. package/src/components/dashboard/Metric/Metric.stories.tsx +65 -0
  36. package/src/components/dashboard/Metric/index.tsx +36 -0
  37. package/src/components/dashboard/Stack/Stack.stories.tsx +61 -0
  38. package/src/components/dashboard/Stack/index.tsx +33 -0
  39. package/src/components/dashboard/Table/Table.stories.tsx +38 -0
  40. package/src/components/dashboard/Table/index.tsx +104 -0
  41. package/src/components/dashboard/Text/Text.stories.tsx +53 -0
  42. package/src/components/dashboard/Text/index.tsx +18 -0
  43. package/src/components/dashboard/index.ts +46 -0
  44. package/src/components/index.ts +17 -0
  45. package/src/components/observability/LogTimeline/LogDetailPane/AttributesTab.tsx +56 -0
  46. package/src/components/observability/LogTimeline/LogDetailPane/JsonTreeView.tsx +139 -0
  47. package/src/components/observability/LogTimeline/LogDetailPane/index.tsx +271 -0
  48. package/src/components/observability/LogTimeline/LogFilter.stories.tsx +66 -0
  49. package/src/components/observability/LogTimeline/LogFilter.test.tsx +696 -0
  50. package/src/components/observability/LogTimeline/LogFilter.tsx +674 -0
  51. package/src/components/observability/LogTimeline/LogRow.tsx +174 -0
  52. package/src/components/observability/LogTimeline/LogTimeline.stories.tsx +154 -0
  53. package/src/components/observability/LogTimeline/index.tsx +542 -0
  54. package/src/components/observability/LogTimeline/shortcuts.ts +18 -0
  55. package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +20 -0
  56. package/src/components/observability/MetricHistogram/index.tsx +303 -0
  57. package/src/components/observability/MetricStat/MetricStat.stories.tsx +30 -0
  58. package/src/components/observability/MetricStat/index.tsx +281 -0
  59. package/src/components/observability/MetricTable/MetricTable.stories.tsx +20 -0
  60. package/src/components/observability/MetricTable/index.tsx +194 -0
  61. package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +28 -0
  62. package/src/components/observability/MetricTimeSeries/index.tsx +462 -0
  63. package/src/components/observability/RawDataTable/RawDataTable.stories.tsx +27 -0
  64. package/src/components/observability/RawDataTable/index.tsx +131 -0
  65. package/src/components/observability/ServiceList/ServiceList.stories.tsx +20 -0
  66. package/src/components/observability/ServiceList/index.tsx +60 -0
  67. package/src/components/observability/ServiceList/shortcuts.ts +6 -0
  68. package/src/components/observability/TabBar/TabBar.stories.tsx +34 -0
  69. package/src/components/observability/TabBar/index.tsx +46 -0
  70. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +51 -0
  71. package/src/components/observability/TraceDetail/index.tsx +53 -0
  72. package/src/components/observability/TraceSearch/TraceSearch.stories.tsx +49 -0
  73. package/src/components/observability/TraceSearch/index.tsx +292 -0
  74. package/src/components/observability/TraceTimeline/DetailPane/AttributesTab.tsx +152 -0
  75. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +128 -0
  76. package/src/components/observability/TraceTimeline/DetailPane/LinksTab.tsx +210 -0
  77. package/src/components/observability/TraceTimeline/DetailPane/index.tsx +174 -0
  78. package/src/components/observability/TraceTimeline/SpanRow.tsx +173 -0
  79. package/src/components/observability/TraceTimeline/TimelineBar.tsx +41 -0
  80. package/src/components/observability/TraceTimeline/Tooltip.tsx +42 -0
  81. package/src/components/observability/TraceTimeline/TraceHeader.tsx +88 -0
  82. package/src/components/observability/TraceTimeline/TraceTimeline.stories.tsx +25 -0
  83. package/src/components/observability/TraceTimeline/index.tsx +478 -0
  84. package/src/components/observability/TraceTimeline/shortcuts.ts +16 -0
  85. package/src/components/observability/__fixtures__/logs.ts +476 -0
  86. package/src/components/observability/__fixtures__/metrics.ts +216 -0
  87. package/src/components/observability/__fixtures__/raw-table.ts +204 -0
  88. package/src/components/observability/__fixtures__/services.ts +8 -0
  89. package/src/components/observability/__fixtures__/trace-summaries.ts +81 -0
  90. package/src/components/observability/__fixtures__/traces.ts +396 -0
  91. package/src/components/observability/index.ts +66 -0
  92. package/src/components/observability/renderers/OtelMetricDiscovery.tsx +77 -0
  93. package/src/components/observability/renderers/OtelMetricHistogram.tsx +29 -0
  94. package/src/components/observability/renderers/OtelMetricStat.tsx +44 -0
  95. package/src/components/observability/renderers/OtelMetricTable.tsx +29 -0
  96. package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +30 -0
  97. package/src/components/observability/renderers/index.ts +5 -0
  98. package/src/components/observability/shared/LoadingSkeleton.tsx +43 -0
  99. package/src/components/observability/types.ts +113 -0
  100. package/src/components/observability/utils/attributes.ts +17 -0
  101. package/src/components/observability/utils/colors.ts +29 -0
  102. package/src/components/observability/utils/flatten-tree.ts +53 -0
  103. package/src/components/observability/utils/lttb.ts +121 -0
  104. package/src/components/observability/utils/time.ts +46 -0
  105. package/src/hooks/use-kopai-data.test.ts +296 -0
  106. package/src/hooks/use-kopai-data.ts +64 -0
  107. package/src/hooks/use-live-logs.test.ts +193 -0
  108. package/src/hooks/use-live-logs.ts +113 -0
  109. package/src/index.ts +15 -0
  110. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +33 -0
  111. package/src/lib/catalog.ts +165 -0
  112. package/src/lib/component-catalog.test.ts +357 -0
  113. package/src/lib/component-catalog.ts +171 -0
  114. package/src/lib/dashboard-datasource.ts +76 -0
  115. package/src/lib/generate-prompt-instructions.test.ts +27 -0
  116. package/src/lib/generate-prompt-instructions.ts +185 -0
  117. package/src/lib/log-buffer.test.ts +88 -0
  118. package/src/lib/log-buffer.ts +62 -0
  119. package/src/lib/observability-catalog.ts +143 -0
  120. package/src/lib/renderer.test.tsx +693 -0
  121. package/src/lib/renderer.tsx +276 -0
  122. package/src/pages/observability.tsx +828 -0
  123. package/src/providers/kopai-provider.tsx +51 -0
  124. package/src/styles/globals.css +46 -0
  125. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Time formatting utilities for trace visualization
3
+ */
4
+
5
+ export function formatDuration(durationMs: number): string {
6
+ if (durationMs < 1) {
7
+ const microseconds = durationMs * 1000;
8
+ return `${microseconds.toFixed(2)}μs`;
9
+ } else if (durationMs < 1000) {
10
+ return `${durationMs.toFixed(2)}ms`;
11
+ } else {
12
+ const seconds = durationMs / 1000;
13
+ return `${seconds.toFixed(2)}s`;
14
+ }
15
+ }
16
+
17
+ export function formatTimestamp(timestampMs: number): string {
18
+ const date = new Date(timestampMs);
19
+ return date.toLocaleString("en-US", {
20
+ year: "numeric",
21
+ month: "short",
22
+ day: "numeric",
23
+ hour: "2-digit",
24
+ minute: "2-digit",
25
+ second: "2-digit",
26
+ timeZoneName: "short",
27
+ });
28
+ }
29
+
30
+ export function calculateRelativeTime(
31
+ timeMs: number,
32
+ minTimeMs: number,
33
+ maxTimeMs: number
34
+ ): number {
35
+ const totalDuration = maxTimeMs - minTimeMs;
36
+ if (totalDuration === 0) return 0;
37
+ return (timeMs - minTimeMs) / totalDuration;
38
+ }
39
+
40
+ export function calculateRelativeDuration(
41
+ durationMs: number,
42
+ totalDurationMs: number
43
+ ): number {
44
+ if (totalDurationMs === 0) return 0;
45
+ return durationMs / totalDurationMs;
46
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
+ import { renderHook, waitFor, act } from "@testing-library/react";
6
+ import { createElement, type ReactNode } from "react";
7
+ import { useKopaiData } from "./use-kopai-data.js";
8
+ import {
9
+ KopaiSDKProvider,
10
+ queryClient,
11
+ type KopaiClient,
12
+ } from "../providers/kopai-provider.js";
13
+ import type { DataSource } from "../lib/component-catalog.js";
14
+
15
+ const createMockClient = () => ({
16
+ searchTracesPage: vi.fn(),
17
+ searchLogsPage: vi.fn(),
18
+ searchMetricsPage: vi.fn(),
19
+ getTrace: vi.fn(),
20
+ discoverMetrics: vi.fn(),
21
+ });
22
+
23
+ type MockClient = ReturnType<typeof createMockClient>;
24
+
25
+ function wrapper(client: KopaiClient) {
26
+ return function Wrapper({ children }: { children: ReactNode }) {
27
+ return createElement(KopaiSDKProvider, { client, children });
28
+ };
29
+ }
30
+
31
+ describe("useKopaiData", () => {
32
+ let mockClient: MockClient;
33
+
34
+ beforeEach(() => {
35
+ mockClient = createMockClient();
36
+ queryClient.clear();
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.restoreAllMocks();
42
+ });
43
+
44
+ describe("initial state", () => {
45
+ it("returns null data when no dataSource", () => {
46
+ const { result } = renderHook(() => useKopaiData(undefined), {
47
+ wrapper: wrapper(mockClient),
48
+ });
49
+
50
+ expect(result.current.data).toBeNull();
51
+ expect(result.current.loading).toBe(false);
52
+ expect(result.current.error).toBeNull();
53
+ });
54
+ });
55
+
56
+ describe("searchTracesPage", () => {
57
+ it("fetches traces and updates state", async () => {
58
+ const mockData = { data: [{ traceId: "123" }], nextCursor: null };
59
+ mockClient.searchTracesPage.mockResolvedValue(mockData);
60
+
61
+ const dataSource: DataSource = {
62
+ method: "searchTracesPage",
63
+ params: { serviceName: "test-service" },
64
+ };
65
+
66
+ const { result } = renderHook(() => useKopaiData(dataSource), {
67
+ wrapper: wrapper(mockClient),
68
+ });
69
+
70
+ expect(result.current.loading).toBe(true);
71
+
72
+ await waitFor(() => {
73
+ expect(result.current.loading).toBe(false);
74
+ });
75
+
76
+ expect(result.current.data).toEqual(mockData);
77
+ expect(result.current.error).toBeNull();
78
+ expect(mockClient.searchTracesPage).toHaveBeenCalledWith(
79
+ { serviceName: "test-service" },
80
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
81
+ );
82
+ });
83
+
84
+ it("handles errors", async () => {
85
+ const error = new Error("Network error");
86
+ mockClient.searchTracesPage.mockRejectedValue(error);
87
+
88
+ const dataSource: DataSource = {
89
+ method: "searchTracesPage",
90
+ params: {},
91
+ };
92
+
93
+ const { result } = renderHook(() => useKopaiData(dataSource), {
94
+ wrapper: wrapper(mockClient),
95
+ });
96
+
97
+ await waitFor(() => {
98
+ expect(result.current.loading).toBe(false);
99
+ });
100
+
101
+ expect(result.current.error).toEqual(error);
102
+ expect(result.current.data).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe("searchLogsPage", () => {
107
+ it("fetches logs", async () => {
108
+ const mockData = { data: [{ body: "log entry" }], nextCursor: null };
109
+ mockClient.searchLogsPage.mockResolvedValue(mockData);
110
+
111
+ const dataSource: DataSource = {
112
+ method: "searchLogsPage",
113
+ params: { serviceName: "test-service" },
114
+ };
115
+
116
+ const { result } = renderHook(() => useKopaiData(dataSource), {
117
+ wrapper: wrapper(mockClient),
118
+ });
119
+
120
+ await waitFor(() => {
121
+ expect(result.current.loading).toBe(false);
122
+ });
123
+
124
+ expect(result.current.data).toEqual(mockData);
125
+ expect(mockClient.searchLogsPage).toHaveBeenCalled();
126
+ });
127
+ });
128
+
129
+ describe("searchMetricsPage", () => {
130
+ it("fetches metrics", async () => {
131
+ const mockData = { data: [{ metricName: "cpu" }], nextCursor: null };
132
+ mockClient.searchMetricsPage.mockResolvedValue(mockData);
133
+
134
+ const dataSource: DataSource = {
135
+ method: "searchMetricsPage",
136
+ params: { metricType: "Gauge" },
137
+ };
138
+
139
+ const { result } = renderHook(() => useKopaiData(dataSource), {
140
+ wrapper: wrapper(mockClient),
141
+ });
142
+
143
+ await waitFor(() => {
144
+ expect(result.current.loading).toBe(false);
145
+ });
146
+
147
+ expect(result.current.data).toEqual(mockData);
148
+ expect(mockClient.searchMetricsPage).toHaveBeenCalled();
149
+ });
150
+ });
151
+
152
+ describe("getTrace", () => {
153
+ it("fetches single trace", async () => {
154
+ const mockData = [{ traceId: "abc", spanId: "123" }];
155
+ mockClient.getTrace.mockResolvedValue(mockData);
156
+
157
+ const dataSource: DataSource = {
158
+ method: "getTrace",
159
+ params: { traceId: "abc" },
160
+ };
161
+
162
+ const { result } = renderHook(() => useKopaiData(dataSource), {
163
+ wrapper: wrapper(mockClient),
164
+ });
165
+
166
+ await waitFor(() => {
167
+ expect(result.current.loading).toBe(false);
168
+ });
169
+
170
+ expect(result.current.data).toEqual(mockData);
171
+ expect(mockClient.getTrace).toHaveBeenCalledWith(
172
+ "abc",
173
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
174
+ );
175
+ });
176
+ });
177
+
178
+ describe("discoverMetrics", () => {
179
+ it("discovers metrics", async () => {
180
+ const mockData = { metrics: [{ name: "cpu_usage", type: "Gauge" }] };
181
+ mockClient.discoverMetrics.mockResolvedValue(mockData);
182
+
183
+ const dataSource: DataSource = {
184
+ method: "discoverMetrics",
185
+ params: {},
186
+ };
187
+
188
+ const { result } = renderHook(() => useKopaiData(dataSource), {
189
+ wrapper: wrapper(mockClient),
190
+ });
191
+
192
+ await waitFor(() => {
193
+ expect(result.current.loading).toBe(false);
194
+ });
195
+
196
+ expect(result.current.data).toEqual(mockData);
197
+ expect(mockClient.discoverMetrics).toHaveBeenCalled();
198
+ });
199
+ });
200
+
201
+ describe("refetch", () => {
202
+ it("refetches same query on refetch()", async () => {
203
+ const mockData1 = { data: [{ id: "1" }], nextCursor: "cursor1" };
204
+ const mockData2 = { data: [{ id: "2" }], nextCursor: null };
205
+ mockClient.searchTracesPage
206
+ .mockResolvedValueOnce(mockData1)
207
+ .mockResolvedValueOnce(mockData2);
208
+
209
+ const dataSource: DataSource = {
210
+ method: "searchTracesPage",
211
+ params: { limit: 10 },
212
+ };
213
+
214
+ const { result } = renderHook(() => useKopaiData(dataSource), {
215
+ wrapper: wrapper(mockClient),
216
+ });
217
+
218
+ await waitFor(() => {
219
+ expect(result.current.data).toEqual(mockData1);
220
+ });
221
+
222
+ act(() => {
223
+ result.current.refetch();
224
+ });
225
+
226
+ await waitFor(() => {
227
+ expect(result.current.data).toEqual(mockData2);
228
+ });
229
+
230
+ expect(mockClient.searchTracesPage).toHaveBeenCalledTimes(2);
231
+ });
232
+ });
233
+
234
+ describe("dataSource change", () => {
235
+ it("triggers new fetch when dataSource changes", async () => {
236
+ const tracesData = { data: [{ traceId: "t1" }] };
237
+ const logsData = { data: [{ body: "log1" }] };
238
+ mockClient.searchTracesPage.mockResolvedValue(tracesData);
239
+ mockClient.searchLogsPage.mockResolvedValue(logsData);
240
+
241
+ const { result, rerender } = renderHook(
242
+ ({ ds }: { ds: DataSource }) => useKopaiData(ds),
243
+ {
244
+ wrapper: wrapper(mockClient),
245
+ initialProps: {
246
+ ds: { method: "searchTracesPage", params: {} } as DataSource,
247
+ },
248
+ }
249
+ );
250
+
251
+ await waitFor(() => {
252
+ expect(result.current.data).toEqual(tracesData);
253
+ });
254
+
255
+ rerender({
256
+ ds: { method: "searchLogsPage", params: {} } as DataSource,
257
+ });
258
+
259
+ await waitFor(() => {
260
+ expect(result.current.data).toEqual(logsData);
261
+ });
262
+
263
+ expect(mockClient.searchTracesPage).toHaveBeenCalledTimes(1);
264
+ expect(mockClient.searchLogsPage).toHaveBeenCalledTimes(1);
265
+ });
266
+ });
267
+
268
+ describe("cleanup", () => {
269
+ it("cancels in-flight request on unmount", async () => {
270
+ let abortSignal: AbortSignal | undefined;
271
+ mockClient.searchTracesPage.mockImplementation(
272
+ async (_: unknown, opts?: { signal?: AbortSignal }) => {
273
+ abortSignal = opts?.signal;
274
+ return new Promise(() => {});
275
+ }
276
+ );
277
+
278
+ const dataSource: DataSource = {
279
+ method: "searchTracesPage",
280
+ params: {},
281
+ };
282
+
283
+ const { unmount } = renderHook(() => useKopaiData(dataSource), {
284
+ wrapper: wrapper(mockClient),
285
+ });
286
+
287
+ await waitFor(() => {
288
+ expect(abortSignal).toBeDefined();
289
+ });
290
+
291
+ unmount();
292
+
293
+ expect(abortSignal?.aborted).toBe(true);
294
+ });
295
+ });
296
+ });
@@ -0,0 +1,64 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { DataSource } from "../lib/component-catalog.js";
3
+ import { useKopaiSDK } from "../providers/kopai-provider.js";
4
+
5
+ export interface UseKopaiDataResult<T> {
6
+ data: T | null;
7
+ loading: boolean;
8
+ error: Error | null;
9
+ refetch: () => void;
10
+ }
11
+
12
+ function fetchForDataSource(
13
+ client: ReturnType<typeof useKopaiSDK>,
14
+ dataSource: DataSource,
15
+ signal: AbortSignal
16
+ ): Promise<unknown> {
17
+ switch (dataSource.method) {
18
+ case "searchTracesPage":
19
+ return client.searchTracesPage(
20
+ dataSource.params as Parameters<typeof client.searchTracesPage>[0],
21
+ { signal }
22
+ );
23
+ case "searchLogsPage":
24
+ return client.searchLogsPage(
25
+ dataSource.params as Parameters<typeof client.searchLogsPage>[0],
26
+ { signal }
27
+ );
28
+ case "searchMetricsPage":
29
+ return client.searchMetricsPage(
30
+ dataSource.params as Parameters<typeof client.searchMetricsPage>[0],
31
+ { signal }
32
+ );
33
+ case "getTrace":
34
+ return client.getTrace(dataSource.params.traceId, { signal });
35
+ case "discoverMetrics":
36
+ return client.discoverMetrics({ signal });
37
+ default: {
38
+ const exhaustiveCheck: never = dataSource;
39
+ throw new Error(
40
+ `Unknown method: ${(exhaustiveCheck as DataSource).method}`
41
+ );
42
+ }
43
+ }
44
+ }
45
+
46
+ export function useKopaiData<T = unknown>(
47
+ dataSource: DataSource | undefined
48
+ ): UseKopaiDataResult<T> {
49
+ const client = useKopaiSDK();
50
+
51
+ const { data, isFetching, error, refetch } = useQuery<unknown, Error>({
52
+ queryKey: ["kopai", dataSource?.method, dataSource?.params],
53
+ queryFn: ({ signal }) => fetchForDataSource(client, dataSource!, signal),
54
+ enabled: !!dataSource,
55
+ refetchInterval: dataSource?.refetchIntervalMs,
56
+ });
57
+
58
+ return {
59
+ data: (data as T) ?? null,
60
+ loading: isFetching,
61
+ error: error ?? null,
62
+ refetch,
63
+ };
64
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
+ import { renderHook, waitFor, act } from "@testing-library/react";
6
+ import { createElement, type ReactNode } from "react";
7
+ import { useLiveLogs } from "./use-live-logs.js";
8
+ import {
9
+ KopaiSDKProvider,
10
+ queryClient,
11
+ type KopaiClient,
12
+ } from "../providers/kopai-provider.js";
13
+
14
+ const BASE_NS = 1700000000000000000n;
15
+ const ts = (offsetMs: number) =>
16
+ (BASE_NS + BigInt(offsetMs) * 1000000n).toString();
17
+
18
+ const createMockClient = () => ({
19
+ searchTracesPage: vi.fn(),
20
+ searchLogsPage: vi.fn(),
21
+ searchMetricsPage: vi.fn(),
22
+ getTrace: vi.fn(),
23
+ discoverMetrics: vi.fn(),
24
+ });
25
+
26
+ function wrapper(client: KopaiClient) {
27
+ return function Wrapper({ children }: { children: ReactNode }) {
28
+ return createElement(KopaiSDKProvider, { client, children });
29
+ };
30
+ }
31
+
32
+ describe("useLiveLogs", () => {
33
+ let mockClient: ReturnType<typeof createMockClient>;
34
+
35
+ beforeEach(() => {
36
+ mockClient = createMockClient();
37
+ queryClient.clear();
38
+ vi.clearAllMocks();
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ it("fetches and returns logs on initial load", async () => {
46
+ const batch = [
47
+ {
48
+ Timestamp: ts(100),
49
+ Body: "log1",
50
+ ServiceName: "svc",
51
+ SeverityNumber: 9,
52
+ },
53
+ {
54
+ Timestamp: ts(200),
55
+ Body: "log2",
56
+ ServiceName: "svc",
57
+ SeverityNumber: 9,
58
+ },
59
+ ];
60
+
61
+ mockClient.searchLogsPage.mockResolvedValue({
62
+ data: batch,
63
+ nextCursor: null,
64
+ });
65
+
66
+ const { result } = renderHook(
67
+ () =>
68
+ useLiveLogs({
69
+ params: { limit: 200 },
70
+ pollIntervalMs: 60_000, // long interval so no refetch during test
71
+ }),
72
+ { wrapper: wrapper(mockClient) }
73
+ );
74
+
75
+ await waitFor(() => {
76
+ expect(result.current.logs).toHaveLength(2);
77
+ });
78
+
79
+ expect(result.current.totalReceived).toBe(2);
80
+ expect(result.current.isLive).toBe(true);
81
+ expect(result.current.error).toBeNull();
82
+
83
+ // First call should not have timestampMin
84
+ const firstCall = mockClient.searchLogsPage.mock.calls[0]![0];
85
+ expect(firstCall.timestampMin).toBeUndefined();
86
+ });
87
+
88
+ it("uses timestampMin on manual refetch after first load", async () => {
89
+ mockClient.searchLogsPage
90
+ .mockResolvedValueOnce({
91
+ data: [
92
+ {
93
+ Timestamp: ts(100),
94
+ Body: "log1",
95
+ ServiceName: "svc",
96
+ SeverityNumber: 9,
97
+ },
98
+ ],
99
+ nextCursor: null,
100
+ })
101
+ .mockResolvedValueOnce({
102
+ data: [
103
+ {
104
+ Timestamp: ts(300),
105
+ Body: "log3",
106
+ ServiceName: "svc",
107
+ SeverityNumber: 9,
108
+ },
109
+ ],
110
+ nextCursor: null,
111
+ });
112
+
113
+ const { result } = renderHook(
114
+ () =>
115
+ useLiveLogs({
116
+ params: { limit: 200 },
117
+ pollIntervalMs: 600_000,
118
+ }),
119
+ { wrapper: wrapper(mockClient) }
120
+ );
121
+
122
+ // Wait for first fetch
123
+ await waitFor(() => {
124
+ expect(result.current.logs).toHaveLength(1);
125
+ });
126
+
127
+ // Pause then resume to trigger refetch
128
+ act(() => {
129
+ result.current.setLive(false);
130
+ });
131
+ act(() => {
132
+ result.current.setLive(true);
133
+ });
134
+
135
+ await waitFor(
136
+ () => {
137
+ expect(
138
+ mockClient.searchLogsPage.mock.calls.length
139
+ ).toBeGreaterThanOrEqual(2);
140
+ },
141
+ { timeout: 3000 }
142
+ );
143
+
144
+ // The refetch call(s) after first should have timestampMin
145
+ const calls = mockClient.searchLogsPage.mock.calls;
146
+ const expectedMin = String(BigInt(ts(100)) + 1n);
147
+ const callsWithTimestampMin = calls.filter(
148
+ (c: unknown[]) =>
149
+ (c[0] as Record<string, unknown>).timestampMin !== undefined
150
+ );
151
+ expect(callsWithTimestampMin.length).toBeGreaterThan(0);
152
+ expect(callsWithTimestampMin[0]![0].timestampMin).toBe(expectedMin);
153
+ });
154
+
155
+ it("setLive(false) sets isLive to false", async () => {
156
+ mockClient.searchLogsPage.mockResolvedValue({ data: [], nextCursor: null });
157
+
158
+ const { result } = renderHook(
159
+ () =>
160
+ useLiveLogs({
161
+ params: { limit: 200 },
162
+ pollIntervalMs: 600_000,
163
+ }),
164
+ { wrapper: wrapper(mockClient) }
165
+ );
166
+
167
+ await waitFor(() => {
168
+ expect(result.current.loading).toBe(false);
169
+ });
170
+
171
+ expect(result.current.isLive).toBe(true);
172
+
173
+ act(() => {
174
+ result.current.setLive(false);
175
+ });
176
+
177
+ expect(result.current.isLive).toBe(false);
178
+ });
179
+
180
+ it("starts as live by default", async () => {
181
+ mockClient.searchLogsPage.mockResolvedValue({ data: [], nextCursor: null });
182
+
183
+ const { result } = renderHook(
184
+ () =>
185
+ useLiveLogs({
186
+ params: { limit: 200 },
187
+ }),
188
+ { wrapper: wrapper(mockClient) }
189
+ );
190
+
191
+ expect(result.current.isLive).toBe(true);
192
+ });
193
+ });
@@ -0,0 +1,113 @@
1
+ import { useState, useRef, useCallback, useEffect } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import type { denormalizedSignals, dataFilterSchemas } from "@kopai/core";
4
+ import { useKopaiSDK } from "../providers/kopai-provider.js";
5
+ import { LogBuffer } from "../lib/log-buffer.js";
6
+
7
+ type OtelLogsRow = denormalizedSignals.OtelLogsRow;
8
+ type LogsDataFilter = dataFilterSchemas.LogsDataFilter;
9
+
10
+ export interface UseLiveLogsOptions {
11
+ params: LogsDataFilter;
12
+ pollIntervalMs?: number;
13
+ maxLogs?: number;
14
+ enabled?: boolean;
15
+ }
16
+
17
+ export interface UseLiveLogsResult {
18
+ logs: OtelLogsRow[];
19
+ isLive: boolean;
20
+ totalReceived: number;
21
+ loading: boolean;
22
+ error: Error | null;
23
+ setLive: (live: boolean) => void;
24
+ }
25
+
26
+ export function useLiveLogs({
27
+ params,
28
+ pollIntervalMs = 3_000,
29
+ maxLogs = 1_000,
30
+ enabled = true,
31
+ }: UseLiveLogsOptions): UseLiveLogsResult {
32
+ const client = useKopaiSDK();
33
+ const bufferRef = useRef(new LogBuffer(maxLogs));
34
+ const [version, setVersion] = useState(0);
35
+ const [isLive, setIsLiveState] = useState(true);
36
+ const totalReceivedRef = useRef(0);
37
+ const hasFetchedOnce = useRef(false);
38
+
39
+ // Reset buffer when params change so stale data from a previous filter
40
+ // doesn't persist while the new query is in flight.
41
+ const paramsKey = JSON.stringify(params);
42
+ const prevParamsKey = useRef(paramsKey);
43
+ useEffect(() => {
44
+ if (prevParamsKey.current !== paramsKey) {
45
+ prevParamsKey.current = paramsKey;
46
+ bufferRef.current.clear();
47
+ hasFetchedOnce.current = false;
48
+ totalReceivedRef.current = 0;
49
+ setVersion((v) => v + 1);
50
+ }
51
+ }, [paramsKey]);
52
+
53
+ const { isFetching, error, refetch } = useQuery<
54
+ { data: OtelLogsRow[]; nextCursor: string | null },
55
+ Error
56
+ >({
57
+ queryKey: ["live-logs", params],
58
+ queryFn: async ({ signal }) => {
59
+ const fetchParams: LogsDataFilter = { ...params };
60
+
61
+ // If params changed since queryFn was scheduled, treat as first fetch
62
+ if (prevParamsKey.current !== JSON.stringify(params)) {
63
+ hasFetchedOnce.current = false;
64
+ }
65
+
66
+ // After first fetch, only get newer logs
67
+ if (hasFetchedOnce.current) {
68
+ const newest = bufferRef.current.getNewestTimestamp();
69
+ if (newest) {
70
+ // Add 1ns to avoid re-fetching the same row
71
+ fetchParams.timestampMin = String(BigInt(newest) + 1n);
72
+ }
73
+ }
74
+
75
+ const result = await client.searchLogsPage(fetchParams, { signal });
76
+ hasFetchedOnce.current = true;
77
+
78
+ if (result.data.length > 0) {
79
+ totalReceivedRef.current += result.data.length;
80
+ bufferRef.current.merge(result.data);
81
+ setVersion((v) => v + 1);
82
+ }
83
+
84
+ return result;
85
+ },
86
+ enabled: enabled,
87
+ refetchInterval: isLive ? pollIntervalMs : false,
88
+ });
89
+
90
+ const setLive = useCallback(
91
+ (live: boolean) => {
92
+ setIsLiveState(live);
93
+ if (live) {
94
+ // Immediate refetch on resume
95
+ refetch();
96
+ }
97
+ },
98
+ [refetch]
99
+ );
100
+
101
+ // Read buffer (version forces re-render)
102
+ void version;
103
+ const logs = bufferRef.current.getAll();
104
+
105
+ return {
106
+ logs,
107
+ isLive,
108
+ totalReceived: totalReceivedRef.current,
109
+ loading: isFetching,
110
+ error: error ?? null,
111
+ setLive,
112
+ };
113
+ }