@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.
- package/README.md +137 -0
- package/dist/index.cjs +5069 -3
- package/dist/index.d.cts +301 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +302 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5010 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +30 -12
- package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +113 -0
- package/src/components/KeyboardShortcuts/ShortcutsHelpDialog.tsx +82 -0
- package/src/components/KeyboardShortcuts/context.ts +23 -0
- package/src/components/KeyboardShortcuts/index.ts +8 -0
- package/src/components/KeyboardShortcuts/types.ts +11 -0
- package/src/components/dashboard/Badge/Badge.stories.tsx +29 -0
- package/src/components/dashboard/Badge/index.tsx +32 -0
- package/src/components/dashboard/Button/Button.stories.tsx +107 -0
- package/src/components/dashboard/Button/index.tsx +63 -0
- package/src/components/dashboard/Card/Card.stories.tsx +81 -0
- package/src/components/dashboard/Card/index.tsx +58 -0
- package/src/components/dashboard/Chart/Chart.stories.tsx +48 -0
- package/src/components/dashboard/Chart/index.tsx +74 -0
- package/src/components/dashboard/DatePicker/DatePicker.stories.tsx +33 -0
- package/src/components/dashboard/DatePicker/index.tsx +41 -0
- package/src/components/dashboard/Divider/Divider.stories.tsx +17 -0
- package/src/components/dashboard/Divider/index.tsx +49 -0
- package/src/components/dashboard/Empty/Empty.stories.tsx +48 -0
- package/src/components/dashboard/Empty/index.tsx +46 -0
- package/src/components/dashboard/Grid/Grid.stories.tsx +52 -0
- package/src/components/dashboard/Grid/index.tsx +26 -0
- package/src/components/dashboard/Heading/Heading.stories.tsx +25 -0
- package/src/components/dashboard/Heading/index.tsx +27 -0
- package/src/components/dashboard/List/List.stories.tsx +37 -0
- package/src/components/dashboard/List/index.tsx +24 -0
- package/src/components/dashboard/Metric/Metric.stories.tsx +65 -0
- package/src/components/dashboard/Metric/index.tsx +36 -0
- package/src/components/dashboard/Stack/Stack.stories.tsx +61 -0
- package/src/components/dashboard/Stack/index.tsx +33 -0
- package/src/components/dashboard/Table/Table.stories.tsx +38 -0
- package/src/components/dashboard/Table/index.tsx +104 -0
- package/src/components/dashboard/Text/Text.stories.tsx +53 -0
- package/src/components/dashboard/Text/index.tsx +18 -0
- package/src/components/dashboard/index.ts +46 -0
- package/src/components/index.ts +17 -0
- package/src/components/observability/LogTimeline/LogDetailPane/AttributesTab.tsx +56 -0
- package/src/components/observability/LogTimeline/LogDetailPane/JsonTreeView.tsx +139 -0
- package/src/components/observability/LogTimeline/LogDetailPane/index.tsx +271 -0
- package/src/components/observability/LogTimeline/LogFilter.stories.tsx +66 -0
- package/src/components/observability/LogTimeline/LogFilter.test.tsx +696 -0
- package/src/components/observability/LogTimeline/LogFilter.tsx +674 -0
- package/src/components/observability/LogTimeline/LogRow.tsx +174 -0
- package/src/components/observability/LogTimeline/LogTimeline.stories.tsx +154 -0
- package/src/components/observability/LogTimeline/index.tsx +542 -0
- package/src/components/observability/LogTimeline/shortcuts.ts +18 -0
- package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +20 -0
- package/src/components/observability/MetricHistogram/index.tsx +303 -0
- package/src/components/observability/MetricStat/MetricStat.stories.tsx +30 -0
- package/src/components/observability/MetricStat/index.tsx +281 -0
- package/src/components/observability/MetricTable/MetricTable.stories.tsx +20 -0
- package/src/components/observability/MetricTable/index.tsx +194 -0
- package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +28 -0
- package/src/components/observability/MetricTimeSeries/index.tsx +462 -0
- package/src/components/observability/RawDataTable/RawDataTable.stories.tsx +27 -0
- package/src/components/observability/RawDataTable/index.tsx +131 -0
- package/src/components/observability/ServiceList/ServiceList.stories.tsx +20 -0
- package/src/components/observability/ServiceList/index.tsx +60 -0
- package/src/components/observability/ServiceList/shortcuts.ts +6 -0
- package/src/components/observability/TabBar/TabBar.stories.tsx +34 -0
- package/src/components/observability/TabBar/index.tsx +46 -0
- package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +51 -0
- package/src/components/observability/TraceDetail/index.tsx +53 -0
- package/src/components/observability/TraceSearch/TraceSearch.stories.tsx +49 -0
- package/src/components/observability/TraceSearch/index.tsx +292 -0
- package/src/components/observability/TraceTimeline/DetailPane/AttributesTab.tsx +152 -0
- package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +128 -0
- package/src/components/observability/TraceTimeline/DetailPane/LinksTab.tsx +210 -0
- package/src/components/observability/TraceTimeline/DetailPane/index.tsx +174 -0
- package/src/components/observability/TraceTimeline/SpanRow.tsx +173 -0
- package/src/components/observability/TraceTimeline/TimelineBar.tsx +41 -0
- package/src/components/observability/TraceTimeline/Tooltip.tsx +42 -0
- package/src/components/observability/TraceTimeline/TraceHeader.tsx +88 -0
- package/src/components/observability/TraceTimeline/TraceTimeline.stories.tsx +25 -0
- package/src/components/observability/TraceTimeline/index.tsx +478 -0
- package/src/components/observability/TraceTimeline/shortcuts.ts +16 -0
- package/src/components/observability/__fixtures__/logs.ts +476 -0
- package/src/components/observability/__fixtures__/metrics.ts +216 -0
- package/src/components/observability/__fixtures__/raw-table.ts +204 -0
- package/src/components/observability/__fixtures__/services.ts +8 -0
- package/src/components/observability/__fixtures__/trace-summaries.ts +81 -0
- package/src/components/observability/__fixtures__/traces.ts +396 -0
- package/src/components/observability/index.ts +66 -0
- package/src/components/observability/renderers/OtelMetricDiscovery.tsx +77 -0
- package/src/components/observability/renderers/OtelMetricHistogram.tsx +29 -0
- package/src/components/observability/renderers/OtelMetricStat.tsx +44 -0
- package/src/components/observability/renderers/OtelMetricTable.tsx +29 -0
- package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +30 -0
- package/src/components/observability/renderers/index.ts +5 -0
- package/src/components/observability/shared/LoadingSkeleton.tsx +43 -0
- package/src/components/observability/types.ts +113 -0
- package/src/components/observability/utils/attributes.ts +17 -0
- package/src/components/observability/utils/colors.ts +29 -0
- package/src/components/observability/utils/flatten-tree.ts +53 -0
- package/src/components/observability/utils/lttb.ts +121 -0
- package/src/components/observability/utils/time.ts +46 -0
- package/src/hooks/use-kopai-data.test.ts +296 -0
- package/src/hooks/use-kopai-data.ts +64 -0
- package/src/hooks/use-live-logs.test.ts +193 -0
- package/src/hooks/use-live-logs.ts +113 -0
- package/src/index.ts +15 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +33 -0
- package/src/lib/catalog.ts +165 -0
- package/src/lib/component-catalog.test.ts +357 -0
- package/src/lib/component-catalog.ts +171 -0
- package/src/lib/dashboard-datasource.ts +76 -0
- package/src/lib/generate-prompt-instructions.test.ts +27 -0
- package/src/lib/generate-prompt-instructions.ts +185 -0
- package/src/lib/log-buffer.test.ts +88 -0
- package/src/lib/log-buffer.ts +62 -0
- package/src/lib/observability-catalog.ts +143 -0
- package/src/lib/renderer.test.tsx +693 -0
- package/src/lib/renderer.tsx +276 -0
- package/src/pages/observability.tsx +828 -0
- package/src/providers/kopai-provider.tsx +51 -0
- package/src/styles/globals.css +46 -0
- 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
|
+
}
|