@kopai/ui 0.0.5 → 0.2.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 +25 -7
- 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 +825 -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,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
5
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
6
|
+
import { LogFilter, type LogFilterProps } from "./LogFilter.js";
|
|
7
|
+
import type { denormalizedSignals } from "@kopai/core";
|
|
8
|
+
|
|
9
|
+
type OtelLogsRow = denormalizedSignals.OtelLogsRow;
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Mock rows for testing
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const mockRows: OtelLogsRow[] = [
|
|
16
|
+
{
|
|
17
|
+
Timestamp: "1700000000000000000",
|
|
18
|
+
Body: "request received",
|
|
19
|
+
SeverityText: "INFO",
|
|
20
|
+
SeverityNumber: 9,
|
|
21
|
+
ServiceName: "api-gateway",
|
|
22
|
+
ScopeName: "com.example.api",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
Timestamp: "1700000001000000000",
|
|
26
|
+
Body: "auth failed",
|
|
27
|
+
SeverityText: "ERROR",
|
|
28
|
+
SeverityNumber: 17,
|
|
29
|
+
ServiceName: "auth-service",
|
|
30
|
+
ScopeName: "com.example.auth",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
Timestamp: "1700000002000000000",
|
|
34
|
+
Body: "db query",
|
|
35
|
+
SeverityText: "DEBUG",
|
|
36
|
+
SeverityNumber: 5,
|
|
37
|
+
ServiceName: "api-gateway",
|
|
38
|
+
ScopeName: "com.example.api",
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function setup(overrides: Partial<LogFilterProps> = {}) {
|
|
47
|
+
const onChange = vi.fn();
|
|
48
|
+
const onSelectedServicesChange = vi.fn();
|
|
49
|
+
const props: LogFilterProps = {
|
|
50
|
+
value: {},
|
|
51
|
+
onChange,
|
|
52
|
+
rows: mockRows,
|
|
53
|
+
selectedServices: [],
|
|
54
|
+
onSelectedServicesChange,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
const result = render(<LogFilter {...props} />);
|
|
58
|
+
return { onChange, onSelectedServicesChange, ...result };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Expand the filter panel (collapsed by default) */
|
|
62
|
+
function expand() {
|
|
63
|
+
fireEvent.click(screen.getByTestId("log-filter-toggle"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getInput(testId: string) {
|
|
67
|
+
return screen.getByTestId(testId) as HTMLInputElement;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getSelect(testId: string) {
|
|
71
|
+
return screen.getByTestId(testId) as HTMLSelectElement;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get the last call's first arg from an onChange mock */
|
|
75
|
+
function lastCall(fn: ReturnType<typeof vi.fn>) {
|
|
76
|
+
const calls = fn.mock.calls;
|
|
77
|
+
return calls[calls.length - 1]![0] as Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Tests
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe("LogFilter", () => {
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// -- Rendering ------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
it("starts collapsed by default", () => {
|
|
92
|
+
setup();
|
|
93
|
+
expect(screen.queryByTestId("filter-serviceName")).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("renders all filter fields when expanded", () => {
|
|
97
|
+
setup();
|
|
98
|
+
expand();
|
|
99
|
+
expect(screen.getByTestId("filter-serviceName")).toBeDefined();
|
|
100
|
+
expect(screen.getByTestId("filter-severityText")).toBeDefined();
|
|
101
|
+
expect(screen.getByTestId("filter-bodyContains")).toBeDefined();
|
|
102
|
+
expect(screen.getByTestId("filter-sortOrder")).toBeDefined();
|
|
103
|
+
expect(screen.getByTestId("filter-limit")).toBeDefined();
|
|
104
|
+
expect(screen.getByTestId("filter-traceId")).toBeDefined();
|
|
105
|
+
expect(screen.getByTestId("filter-spanId")).toBeDefined();
|
|
106
|
+
expect(screen.getByTestId("filter-scopeName")).toBeDefined();
|
|
107
|
+
expect(screen.getByTestId("filter-logAttributes")).toBeDefined();
|
|
108
|
+
expect(screen.getByTestId("filter-resourceAttributes")).toBeDefined();
|
|
109
|
+
expect(screen.getByTestId("filter-scopeAttributes")).toBeDefined();
|
|
110
|
+
expect(screen.getByTestId("filter-lookback")).toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// -- Collapsible panel ----------------------------------------------------
|
|
114
|
+
|
|
115
|
+
it("collapses and expands the filter panel", () => {
|
|
116
|
+
setup();
|
|
117
|
+
const toggle = screen.getByTestId("log-filter-toggle");
|
|
118
|
+
|
|
119
|
+
// Initially collapsed
|
|
120
|
+
expect(screen.queryByTestId("filter-serviceName")).toBeNull();
|
|
121
|
+
|
|
122
|
+
// Expand
|
|
123
|
+
fireEvent.click(toggle);
|
|
124
|
+
expect(screen.getByTestId("filter-serviceName")).toBeDefined();
|
|
125
|
+
|
|
126
|
+
// Collapse
|
|
127
|
+
fireEvent.click(toggle);
|
|
128
|
+
expect(screen.queryByTestId("filter-serviceName")).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// -- Filter summary -------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
it("shows filter summary when collapsed with active filters", () => {
|
|
134
|
+
setup({
|
|
135
|
+
value: { severityText: "ERROR", limit: 200 },
|
|
136
|
+
selectedServices: ["api-gateway"],
|
|
137
|
+
});
|
|
138
|
+
const summary = screen.getByTestId("filter-summary");
|
|
139
|
+
expect(summary.textContent).toContain("service:api-gateway");
|
|
140
|
+
expect(summary.textContent).toContain("severity:ERROR");
|
|
141
|
+
expect(summary.textContent).toContain("limit:200");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("shows multi-service count in summary", () => {
|
|
145
|
+
setup({
|
|
146
|
+
selectedServices: ["api-gateway", "auth-service"],
|
|
147
|
+
});
|
|
148
|
+
const summary = screen.getByTestId("filter-summary");
|
|
149
|
+
expect(summary.textContent).toContain("services:2");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does not show filter summary when no active filters", () => {
|
|
153
|
+
setup({ value: {} });
|
|
154
|
+
expect(screen.queryByTestId("filter-summary")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("hides filter summary when expanded", () => {
|
|
158
|
+
setup({
|
|
159
|
+
selectedServices: ["api-gateway"],
|
|
160
|
+
});
|
|
161
|
+
expect(screen.getByTestId("filter-summary")).toBeDefined();
|
|
162
|
+
expand();
|
|
163
|
+
expect(screen.queryByTestId("filter-summary")).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// -- Service multi-select -------------------------------------------------
|
|
167
|
+
|
|
168
|
+
it("renders service multi-select with options from rows", () => {
|
|
169
|
+
setup();
|
|
170
|
+
expand();
|
|
171
|
+
// Open the dropdown
|
|
172
|
+
fireEvent.click(screen.getByTestId("filter-serviceName-trigger"));
|
|
173
|
+
expect(screen.getByTestId("filter-serviceName-dropdown")).toBeDefined();
|
|
174
|
+
expect(
|
|
175
|
+
screen.getByTestId("filter-serviceName-option-api-gateway")
|
|
176
|
+
).toBeDefined();
|
|
177
|
+
expect(
|
|
178
|
+
screen.getByTestId("filter-serviceName-option-auth-service")
|
|
179
|
+
).toBeDefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("shows 'All' when no services selected", () => {
|
|
183
|
+
setup();
|
|
184
|
+
expand();
|
|
185
|
+
expect(
|
|
186
|
+
screen.getByTestId("filter-serviceName-trigger").textContent
|
|
187
|
+
).toContain("All");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("shows service name when 1 selected", () => {
|
|
191
|
+
setup({ selectedServices: ["api-gateway"] });
|
|
192
|
+
expand();
|
|
193
|
+
expect(
|
|
194
|
+
screen.getByTestId("filter-serviceName-trigger").textContent
|
|
195
|
+
).toContain("api-gateway");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("shows count when multiple selected", () => {
|
|
199
|
+
setup({ selectedServices: ["api-gateway", "auth-service"] });
|
|
200
|
+
expand();
|
|
201
|
+
expect(
|
|
202
|
+
screen.getByTestId("filter-serviceName-trigger").textContent
|
|
203
|
+
).toContain("2 selected");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("toggling a service checkbox calls onSelectedServicesChange", () => {
|
|
207
|
+
const { onSelectedServicesChange } = setup();
|
|
208
|
+
expand();
|
|
209
|
+
fireEvent.click(screen.getByTestId("filter-serviceName-trigger"));
|
|
210
|
+
fireEvent.click(
|
|
211
|
+
screen.getByTestId("filter-serviceName-option-api-gateway")
|
|
212
|
+
);
|
|
213
|
+
expect(onSelectedServicesChange).toHaveBeenCalledWith(["api-gateway"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("selecting 1 service sets serviceName in onChange", () => {
|
|
217
|
+
const { onChange } = setup();
|
|
218
|
+
expand();
|
|
219
|
+
fireEvent.click(screen.getByTestId("filter-serviceName-trigger"));
|
|
220
|
+
fireEvent.click(
|
|
221
|
+
screen.getByTestId("filter-serviceName-option-api-gateway")
|
|
222
|
+
);
|
|
223
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
224
|
+
expect.objectContaining({ serviceName: "api-gateway" })
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("selecting 2 services removes serviceName from onChange", () => {
|
|
229
|
+
const { onChange } = setup({
|
|
230
|
+
selectedServices: ["api-gateway"],
|
|
231
|
+
});
|
|
232
|
+
expand();
|
|
233
|
+
fireEvent.click(screen.getByTestId("filter-serviceName-trigger"));
|
|
234
|
+
fireEvent.click(
|
|
235
|
+
screen.getByTestId("filter-serviceName-option-auth-service")
|
|
236
|
+
);
|
|
237
|
+
const call = lastCall(onChange);
|
|
238
|
+
expect(call.serviceName).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("clearing services removes serviceName from onChange", () => {
|
|
242
|
+
const { onChange, onSelectedServicesChange } = setup({
|
|
243
|
+
selectedServices: ["api-gateway"],
|
|
244
|
+
});
|
|
245
|
+
expand();
|
|
246
|
+
fireEvent.click(screen.getByTestId("filter-serviceName-trigger"));
|
|
247
|
+
fireEvent.click(screen.getByTestId("filter-serviceName-clear"));
|
|
248
|
+
expect(onSelectedServicesChange).toHaveBeenCalledWith([]);
|
|
249
|
+
const call = lastCall(onChange);
|
|
250
|
+
expect(call.serviceName).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// -- Dynamic options from rows --------------------------------------------
|
|
254
|
+
|
|
255
|
+
it("renders severity options from rows", () => {
|
|
256
|
+
setup();
|
|
257
|
+
expand();
|
|
258
|
+
const select = getSelect("filter-severityText");
|
|
259
|
+
const options = Array.from(select.options).map((o) => o.value);
|
|
260
|
+
expect(options).toContain("INFO");
|
|
261
|
+
expect(options).toContain("ERROR");
|
|
262
|
+
expect(options).toContain("DEBUG");
|
|
263
|
+
expect(options).toContain("");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("renders scope options from rows", () => {
|
|
267
|
+
setup();
|
|
268
|
+
expand();
|
|
269
|
+
const select = getSelect("filter-scopeName");
|
|
270
|
+
const options = Array.from(select.options).map((o) => o.value);
|
|
271
|
+
expect(options).toContain("com.example.api");
|
|
272
|
+
expect(options).toContain("com.example.auth");
|
|
273
|
+
expect(options).toContain("");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("renders empty service multi-select when no rows", () => {
|
|
277
|
+
setup({ rows: [] });
|
|
278
|
+
expand();
|
|
279
|
+
fireEvent.click(screen.getByTestId("filter-serviceName-trigger"));
|
|
280
|
+
// Should show "No options"
|
|
281
|
+
expect(
|
|
282
|
+
screen.getByTestId("filter-serviceName-dropdown").textContent
|
|
283
|
+
).toContain("No options");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// -- Select changes fire immediately --------------------------------------
|
|
287
|
+
|
|
288
|
+
it("severityText select fires onChange immediately", () => {
|
|
289
|
+
const { onChange } = setup();
|
|
290
|
+
expand();
|
|
291
|
+
const select = getSelect("filter-severityText");
|
|
292
|
+
|
|
293
|
+
fireEvent.change(select, { target: { value: "ERROR" } });
|
|
294
|
+
|
|
295
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
296
|
+
expect.objectContaining({ severityText: "ERROR" })
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("scopeName select fires onChange immediately", () => {
|
|
301
|
+
const { onChange } = setup();
|
|
302
|
+
expand();
|
|
303
|
+
const select = getSelect("filter-scopeName");
|
|
304
|
+
|
|
305
|
+
fireEvent.change(select, { target: { value: "com.example.api" } });
|
|
306
|
+
|
|
307
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
308
|
+
expect.objectContaining({ scopeName: "com.example.api" })
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("sortOrder select fires onChange immediately", () => {
|
|
313
|
+
const { onChange } = setup({ value: { sortOrder: "DESC" } });
|
|
314
|
+
expand();
|
|
315
|
+
const select = getSelect("filter-sortOrder");
|
|
316
|
+
|
|
317
|
+
fireEvent.change(select, { target: { value: "ASC" } });
|
|
318
|
+
|
|
319
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
320
|
+
expect.objectContaining({ sortOrder: "ASC" })
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("clearing severityText removes it from output", () => {
|
|
325
|
+
const { onChange } = setup({ value: { severityText: "ERROR" } });
|
|
326
|
+
expand();
|
|
327
|
+
const select = getSelect("filter-severityText");
|
|
328
|
+
|
|
329
|
+
fireEvent.change(select, { target: { value: "" } });
|
|
330
|
+
|
|
331
|
+
const call = lastCall(onChange);
|
|
332
|
+
expect(call.severityText).toBeUndefined();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// -- Text input debounce --------------------------------------------------
|
|
336
|
+
|
|
337
|
+
it("bodyContains text input debounces onChange", () => {
|
|
338
|
+
const { onChange } = setup();
|
|
339
|
+
expand();
|
|
340
|
+
const input = getInput("filter-bodyContains");
|
|
341
|
+
|
|
342
|
+
fireEvent.change(input, { target: { value: "GET /api" } });
|
|
343
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
344
|
+
|
|
345
|
+
act(() => vi.advanceTimersByTime(500));
|
|
346
|
+
|
|
347
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
348
|
+
expect.objectContaining({ bodyContains: "GET /api" })
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("traceId text input debounces onChange", () => {
|
|
353
|
+
const { onChange } = setup();
|
|
354
|
+
expand();
|
|
355
|
+
const input = getInput("filter-traceId");
|
|
356
|
+
|
|
357
|
+
fireEvent.change(input, { target: { value: "abc123" } });
|
|
358
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
359
|
+
|
|
360
|
+
act(() => vi.advanceTimersByTime(500));
|
|
361
|
+
|
|
362
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
363
|
+
expect.objectContaining({ traceId: "abc123" })
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("spanId text input debounces onChange", () => {
|
|
368
|
+
const { onChange } = setup();
|
|
369
|
+
expand();
|
|
370
|
+
const input = getInput("filter-spanId");
|
|
371
|
+
|
|
372
|
+
fireEvent.change(input, { target: { value: "span456" } });
|
|
373
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
374
|
+
|
|
375
|
+
act(() => vi.advanceTimersByTime(500));
|
|
376
|
+
|
|
377
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
378
|
+
expect.objectContaining({ spanId: "span456" })
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("clearing a text field removes it from output", () => {
|
|
383
|
+
const { onChange } = setup({ value: { bodyContains: "foo" } });
|
|
384
|
+
expand();
|
|
385
|
+
const input = getInput("filter-bodyContains");
|
|
386
|
+
|
|
387
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
388
|
+
act(() => vi.advanceTimersByTime(500));
|
|
389
|
+
|
|
390
|
+
const call = lastCall(onChange);
|
|
391
|
+
expect(call.bodyContains).toBeUndefined();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// -- Limit input ----------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
it("limit number input fires onChange immediately with valid value", () => {
|
|
397
|
+
const { onChange } = setup();
|
|
398
|
+
expand();
|
|
399
|
+
const input = getInput("filter-limit");
|
|
400
|
+
|
|
401
|
+
fireEvent.change(input, { target: { value: "500" } });
|
|
402
|
+
|
|
403
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
404
|
+
expect.objectContaining({ limit: 500 })
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("limit rejects values outside 1-1000", () => {
|
|
409
|
+
const { onChange } = setup();
|
|
410
|
+
expand();
|
|
411
|
+
const input = getInput("filter-limit");
|
|
412
|
+
|
|
413
|
+
fireEvent.change(input, { target: { value: "0" } });
|
|
414
|
+
|
|
415
|
+
const call = lastCall(onChange);
|
|
416
|
+
expect(call.limit).toBeUndefined();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("limit rejects values above 1000", () => {
|
|
420
|
+
const { onChange } = setup();
|
|
421
|
+
expand();
|
|
422
|
+
const input = getInput("filter-limit");
|
|
423
|
+
|
|
424
|
+
fireEvent.change(input, { target: { value: "2000" } });
|
|
425
|
+
|
|
426
|
+
const call = lastCall(onChange);
|
|
427
|
+
expect(call.limit).toBeUndefined();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// -- Attribute parsing ----------------------------------------------------
|
|
431
|
+
|
|
432
|
+
it("logAttributes parses key=value correctly", () => {
|
|
433
|
+
const { onChange } = setup();
|
|
434
|
+
expand();
|
|
435
|
+
const input = getInput("filter-logAttributes");
|
|
436
|
+
|
|
437
|
+
fireEvent.change(input, { target: { value: "env=prod" } });
|
|
438
|
+
act(() => vi.advanceTimersByTime(500));
|
|
439
|
+
|
|
440
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
441
|
+
expect.objectContaining({ logAttributes: { env: "prod" } })
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("resourceAttributes parses key=value correctly", () => {
|
|
446
|
+
const { onChange } = setup();
|
|
447
|
+
expand();
|
|
448
|
+
const input = getInput("filter-resourceAttributes");
|
|
449
|
+
|
|
450
|
+
fireEvent.change(input, { target: { value: "host=web-01" } });
|
|
451
|
+
act(() => vi.advanceTimersByTime(500));
|
|
452
|
+
|
|
453
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
454
|
+
expect.objectContaining({ resourceAttributes: { host: "web-01" } })
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("scopeAttributes parses key=value correctly", () => {
|
|
459
|
+
const { onChange } = setup();
|
|
460
|
+
expand();
|
|
461
|
+
const input = getInput("filter-scopeAttributes");
|
|
462
|
+
|
|
463
|
+
fireEvent.change(input, { target: { value: "lib=otel" } });
|
|
464
|
+
act(() => vi.advanceTimersByTime(500));
|
|
465
|
+
|
|
466
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
467
|
+
expect.objectContaining({ scopeAttributes: { lib: "otel" } })
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("attribute input with no '=' does not set the field", () => {
|
|
472
|
+
const { onChange } = setup();
|
|
473
|
+
expand();
|
|
474
|
+
const input = getInput("filter-logAttributes");
|
|
475
|
+
|
|
476
|
+
fireEvent.change(input, { target: { value: "no-equals" } });
|
|
477
|
+
act(() => vi.advanceTimersByTime(500));
|
|
478
|
+
|
|
479
|
+
const call = lastCall(onChange);
|
|
480
|
+
expect(call.logAttributes).toBeUndefined();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("attribute input with empty key does not set the field", () => {
|
|
484
|
+
const { onChange } = setup();
|
|
485
|
+
expand();
|
|
486
|
+
const input = getInput("filter-logAttributes");
|
|
487
|
+
|
|
488
|
+
fireEvent.change(input, { target: { value: "=value" } });
|
|
489
|
+
act(() => vi.advanceTimersByTime(500));
|
|
490
|
+
|
|
491
|
+
const call = lastCall(onChange);
|
|
492
|
+
expect(call.logAttributes).toBeUndefined();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("attribute with value containing '=' parses correctly", () => {
|
|
496
|
+
const { onChange } = setup();
|
|
497
|
+
expand();
|
|
498
|
+
const input = getInput("filter-logAttributes");
|
|
499
|
+
|
|
500
|
+
fireEvent.change(input, { target: { value: "query=a=b" } });
|
|
501
|
+
act(() => vi.advanceTimersByTime(500));
|
|
502
|
+
|
|
503
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
504
|
+
expect.objectContaining({ logAttributes: { query: "a=b" } })
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("multiple comma-separated attributes parse correctly", () => {
|
|
509
|
+
const { onChange } = setup();
|
|
510
|
+
expand();
|
|
511
|
+
const input = getInput("filter-logAttributes");
|
|
512
|
+
|
|
513
|
+
fireEvent.change(input, {
|
|
514
|
+
target: { value: "env=prod, region=us-east-1" },
|
|
515
|
+
});
|
|
516
|
+
act(() => vi.advanceTimersByTime(500));
|
|
517
|
+
|
|
518
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
519
|
+
expect.objectContaining({
|
|
520
|
+
logAttributes: { env: "prod", region: "us-east-1" },
|
|
521
|
+
})
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("comma-separated with some invalid entries only parses valid ones", () => {
|
|
526
|
+
const { onChange } = setup();
|
|
527
|
+
expand();
|
|
528
|
+
const input = getInput("filter-logAttributes");
|
|
529
|
+
|
|
530
|
+
fireEvent.change(input, {
|
|
531
|
+
target: { value: "env=prod, badentry, region=eu" },
|
|
532
|
+
});
|
|
533
|
+
act(() => vi.advanceTimersByTime(500));
|
|
534
|
+
|
|
535
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
536
|
+
expect.objectContaining({
|
|
537
|
+
logAttributes: { env: "prod", region: "eu" },
|
|
538
|
+
})
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// -- Lookback select ------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
it("lookback select computes correct nanosecond timestampMin", () => {
|
|
545
|
+
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
|
|
546
|
+
const { onChange } = setup();
|
|
547
|
+
expand();
|
|
548
|
+
const select = getSelect("filter-lookback");
|
|
549
|
+
|
|
550
|
+
fireEvent.change(select, { target: { value: "0" } });
|
|
551
|
+
|
|
552
|
+
const call = lastCall(onChange);
|
|
553
|
+
const expectedMs = Date.now() - 5 * 60_000;
|
|
554
|
+
const expectedNs = String(BigInt(Math.floor(expectedMs)) * 1_000_000n);
|
|
555
|
+
expect(call.timestampMin).toBe(expectedNs);
|
|
556
|
+
expect(call.timestampMax).toBeUndefined();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("lookback 'All time' clears timestampMin", () => {
|
|
560
|
+
const { onChange } = setup({ value: { timestampMin: "123" } });
|
|
561
|
+
expand();
|
|
562
|
+
const select = getSelect("filter-lookback");
|
|
563
|
+
|
|
564
|
+
fireEvent.change(select, { target: { value: "-1" } });
|
|
565
|
+
|
|
566
|
+
const call = lastCall(onChange);
|
|
567
|
+
expect(call.timestampMin).toBeUndefined();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// -- Time mode switching --------------------------------------------------
|
|
571
|
+
|
|
572
|
+
it("switching to absolute mode shows datetime inputs", () => {
|
|
573
|
+
setup();
|
|
574
|
+
expand();
|
|
575
|
+
expect(screen.queryByTestId("filter-timestampMin")).toBeNull();
|
|
576
|
+
|
|
577
|
+
fireEvent.click(screen.getByTestId("time-mode-absolute"));
|
|
578
|
+
|
|
579
|
+
expect(screen.getByTestId("filter-timestampMin")).toBeDefined();
|
|
580
|
+
expect(screen.getByTestId("filter-timestampMax")).toBeDefined();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("switching time modes clears timestamp values", () => {
|
|
584
|
+
const { onChange } = setup({ value: { timestampMin: "123000000000" } });
|
|
585
|
+
expand();
|
|
586
|
+
|
|
587
|
+
fireEvent.click(screen.getByTestId("time-mode-absolute"));
|
|
588
|
+
|
|
589
|
+
const call = lastCall(onChange);
|
|
590
|
+
expect(call.timestampMin).toBeUndefined();
|
|
591
|
+
expect(call.timestampMax).toBeUndefined();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("switching back to lookback clears timestamps", () => {
|
|
595
|
+
const { onChange } = setup({
|
|
596
|
+
value: { timestampMin: "123", timestampMax: "456" },
|
|
597
|
+
});
|
|
598
|
+
expand();
|
|
599
|
+
|
|
600
|
+
fireEvent.click(screen.getByTestId("time-mode-absolute"));
|
|
601
|
+
onChange.mockClear();
|
|
602
|
+
|
|
603
|
+
fireEvent.click(screen.getByTestId("time-mode-lookback"));
|
|
604
|
+
|
|
605
|
+
const call = lastCall(onChange);
|
|
606
|
+
expect(call.timestampMin).toBeUndefined();
|
|
607
|
+
expect(call.timestampMax).toBeUndefined();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// -- Absolute time inputs -------------------------------------------------
|
|
611
|
+
|
|
612
|
+
it("absolute timestampMin sets nanosecond string", () => {
|
|
613
|
+
const { onChange } = setup();
|
|
614
|
+
expand();
|
|
615
|
+
fireEvent.click(screen.getByTestId("time-mode-absolute"));
|
|
616
|
+
onChange.mockClear();
|
|
617
|
+
|
|
618
|
+
const input = getInput("filter-timestampMin");
|
|
619
|
+
fireEvent.change(input, { target: { value: "2025-01-15T12:00" } });
|
|
620
|
+
|
|
621
|
+
const call = lastCall(onChange);
|
|
622
|
+
const expectedMs = new Date("2025-01-15T12:00").getTime();
|
|
623
|
+
const expectedNs = String(BigInt(Math.floor(expectedMs)) * 1_000_000n);
|
|
624
|
+
expect(call.timestampMin).toBe(expectedNs);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("absolute timestampMax sets nanosecond string", () => {
|
|
628
|
+
const { onChange } = setup();
|
|
629
|
+
expand();
|
|
630
|
+
fireEvent.click(screen.getByTestId("time-mode-absolute"));
|
|
631
|
+
onChange.mockClear();
|
|
632
|
+
|
|
633
|
+
const input = getInput("filter-timestampMax");
|
|
634
|
+
fireEvent.change(input, { target: { value: "2025-01-15T13:00" } });
|
|
635
|
+
|
|
636
|
+
const call = lastCall(onChange);
|
|
637
|
+
const expectedMs = new Date("2025-01-15T13:00").getTime();
|
|
638
|
+
const expectedNs = String(BigInt(Math.floor(expectedMs)) * 1_000_000n);
|
|
639
|
+
expect(call.timestampMax).toBe(expectedNs);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("clearing absolute time input removes the field", () => {
|
|
643
|
+
const { onChange } = setup({ value: { timestampMin: "123000000000" } });
|
|
644
|
+
expand();
|
|
645
|
+
fireEvent.click(screen.getByTestId("time-mode-absolute"));
|
|
646
|
+
onChange.mockClear();
|
|
647
|
+
|
|
648
|
+
const input = getInput("filter-timestampMin");
|
|
649
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
650
|
+
|
|
651
|
+
const call = lastCall(onChange);
|
|
652
|
+
expect(call.timestampMin).toBeUndefined();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// -- Multiple debounced fields at once ------------------------------------
|
|
656
|
+
|
|
657
|
+
it("multiple debounced text fields combine in single onChange", () => {
|
|
658
|
+
const { onChange } = setup();
|
|
659
|
+
expand();
|
|
660
|
+
|
|
661
|
+
fireEvent.change(getInput("filter-bodyContains"), {
|
|
662
|
+
target: { value: "error" },
|
|
663
|
+
});
|
|
664
|
+
fireEvent.change(getInput("filter-traceId"), {
|
|
665
|
+
target: { value: "abc" },
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
669
|
+
|
|
670
|
+
act(() => vi.advanceTimersByTime(500));
|
|
671
|
+
|
|
672
|
+
const combined = lastCall(onChange);
|
|
673
|
+
expect(combined.bodyContains).toBe("error");
|
|
674
|
+
expect(combined.traceId).toBe("abc");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// -- Preserves non-text values when debounced text fires ------------------
|
|
678
|
+
|
|
679
|
+
it("debounced text onChange preserves existing select values", () => {
|
|
680
|
+
const { onChange } = setup({
|
|
681
|
+
value: { severityText: "WARN", sortOrder: "DESC", limit: 100 },
|
|
682
|
+
});
|
|
683
|
+
expand();
|
|
684
|
+
|
|
685
|
+
fireEvent.change(getInput("filter-bodyContains"), {
|
|
686
|
+
target: { value: "api" },
|
|
687
|
+
});
|
|
688
|
+
act(() => vi.advanceTimersByTime(500));
|
|
689
|
+
|
|
690
|
+
const call = lastCall(onChange);
|
|
691
|
+
expect(call.severityText).toBe("WARN");
|
|
692
|
+
expect(call.sortOrder).toBe("DESC");
|
|
693
|
+
expect(call.limit).toBe(100);
|
|
694
|
+
expect(call.bodyContains).toBe("api");
|
|
695
|
+
});
|
|
696
|
+
});
|