@kopai/ui 0.0.5 → 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 +25 -7
  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,674 @@
1
+ /**
2
+ * LogFilter – collapsible filter panel for LogsDataFilter params.
3
+ * Follows the same visual pattern as TraceSearch filters.
4
+ */
5
+
6
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
7
+ import type { dataFilterSchemas, denormalizedSignals } from "@kopai/core";
8
+
9
+ type LogsDataFilter = dataFilterSchemas.LogsDataFilter;
10
+ type OtelLogsRow = denormalizedSignals.OtelLogsRow;
11
+ type FilterValue = Partial<LogsDataFilter>;
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const LOOKBACK_OPTIONS = [
18
+ { label: "Last 5 Minutes", ms: 5 * 60_000 },
19
+ { label: "Last 15 Minutes", ms: 15 * 60_000 },
20
+ { label: "Last 30 Minutes", ms: 30 * 60_000 },
21
+ { label: "Last 1 Hour", ms: 60 * 60_000 },
22
+ { label: "Last 2 Hours", ms: 2 * 60 * 60_000 },
23
+ { label: "Last 6 Hours", ms: 6 * 60 * 60_000 },
24
+ { label: "Last 12 Hours", ms: 12 * 60 * 60_000 },
25
+ { label: "Last 24 Hours", ms: 24 * 60 * 60_000 },
26
+ ] as const;
27
+
28
+ const DEBOUNCE_MS = 500;
29
+
30
+ type TimeMode = "lookback" | "absolute";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Convert ms epoch to nanosecond string */
37
+ function msToNs(ms: number): string {
38
+ return String(BigInt(Math.floor(ms)) * 1_000_000n);
39
+ }
40
+
41
+ /** Parse comma-separated "key=value" pairs into a record, or undefined if all invalid */
42
+ function parseKeyValues(input: string): Record<string, string> | undefined {
43
+ const result: Record<string, string> = {};
44
+ let hasAny = false;
45
+ for (const part of input.split(",")) {
46
+ const trimmed = part.trim();
47
+ if (!trimmed) continue;
48
+ const eqIdx = trimmed.indexOf("=");
49
+ if (eqIdx < 1) continue;
50
+ const key = trimmed.slice(0, eqIdx).trim();
51
+ const val = trimmed.slice(eqIdx + 1).trim();
52
+ if (!key) continue;
53
+ result[key] = val;
54
+ hasAny = true;
55
+ }
56
+ return hasAny ? result : undefined;
57
+ }
58
+
59
+ /** Format a datetime-local string from a nanosecond timestamp */
60
+ function nsToDatetimeLocal(ns: string | undefined): string {
61
+ if (!ns) return "";
62
+ const ms = Number(BigInt(ns) / 1_000_000n);
63
+ const d = new Date(ms);
64
+ const pad = (n: number) => String(n).padStart(2, "0");
65
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
66
+ }
67
+
68
+ /** Build compact summary of active filters */
69
+ function buildFilterSummary(
70
+ value: FilterValue,
71
+ selectedServices: string[]
72
+ ): string {
73
+ const parts: string[] = [];
74
+ if (selectedServices.length === 1) {
75
+ parts.push(`service:${selectedServices[0]}`);
76
+ } else if (selectedServices.length > 1) {
77
+ parts.push(`services:${selectedServices.length}`);
78
+ }
79
+ if (value.severityText) parts.push(`severity:${value.severityText}`);
80
+ if (value.scopeName) parts.push(`scope:${value.scopeName}`);
81
+ if (value.bodyContains) parts.push(`body:"${value.bodyContains}"`);
82
+ if (value.traceId) parts.push(`trace:${value.traceId.slice(0, 8)}…`);
83
+ if (value.spanId) parts.push(`span:${value.spanId.slice(0, 8)}…`);
84
+ if (value.limit != null) parts.push(`limit:${value.limit}`);
85
+ if (value.sortOrder === "ASC") parts.push("sort:oldest");
86
+ return parts.join(" | ");
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Shared input classes
91
+ // ---------------------------------------------------------------------------
92
+
93
+ const INPUT_CLS =
94
+ "w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground/50";
95
+
96
+ const LABEL_CLS = "text-xs text-muted-foreground";
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Debounce hook
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function useDebouncedValue<T>(value: T, delayMs: number): T {
103
+ const [debounced, setDebounced] = useState(value);
104
+ useEffect(() => {
105
+ const id = setTimeout(() => setDebounced(value), delayMs);
106
+ return () => clearTimeout(id);
107
+ }, [value, delayMs]);
108
+ return debounced;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // MultiSelect dropdown
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function MultiSelect({
116
+ options,
117
+ selected,
118
+ onChange,
119
+ testId,
120
+ }: {
121
+ options: string[];
122
+ selected: string[];
123
+ onChange: (next: string[]) => void;
124
+ testId?: string;
125
+ }) {
126
+ const [dropOpen, setDropOpen] = useState(false);
127
+ const ref = useRef<HTMLDivElement>(null);
128
+
129
+ // Close on click outside
130
+ useEffect(() => {
131
+ if (!dropOpen) return;
132
+ const handler = (e: MouseEvent) => {
133
+ if (ref.current && !ref.current.contains(e.target as Node)) {
134
+ setDropOpen(false);
135
+ }
136
+ };
137
+ document.addEventListener("mousedown", handler);
138
+ return () => document.removeEventListener("mousedown", handler);
139
+ }, [dropOpen]);
140
+
141
+ const toggle = (val: string) => {
142
+ if (selected.includes(val)) {
143
+ onChange(selected.filter((s) => s !== val));
144
+ } else {
145
+ onChange([...selected, val]);
146
+ }
147
+ };
148
+
149
+ const label =
150
+ selected.length === 0
151
+ ? "All"
152
+ : selected.length === 1
153
+ ? selected[0]
154
+ : `${selected.length} selected`;
155
+
156
+ return (
157
+ <div ref={ref} className="relative" data-testid={testId}>
158
+ <button
159
+ type="button"
160
+ onClick={() => setDropOpen((v) => !v)}
161
+ className={`${INPUT_CLS} text-left flex items-center justify-between`}
162
+ data-testid={testId ? `${testId}-trigger` : undefined}
163
+ >
164
+ <span className="truncate">{label}</span>
165
+ <span className="text-muted-foreground text-xs ml-1">
166
+ {dropOpen ? "▲" : "▼"}
167
+ </span>
168
+ </button>
169
+ {dropOpen && (
170
+ <div
171
+ className="absolute z-10 mt-1 w-full bg-background border border-border rounded shadow-lg max-h-48 overflow-y-auto"
172
+ data-testid={testId ? `${testId}-dropdown` : undefined}
173
+ >
174
+ {options.length === 0 && (
175
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">
176
+ No options
177
+ </div>
178
+ )}
179
+ {options.map((opt) => (
180
+ <label
181
+ key={opt}
182
+ className="flex items-center gap-2 px-2 py-1.5 hover:bg-muted/30 cursor-pointer text-sm"
183
+ >
184
+ <input
185
+ type="checkbox"
186
+ checked={selected.includes(opt)}
187
+ onChange={() => toggle(opt)}
188
+ className="accent-foreground"
189
+ data-testid={testId ? `${testId}-option-${opt}` : undefined}
190
+ />
191
+ <span className="truncate">{opt}</span>
192
+ </label>
193
+ ))}
194
+ {selected.length > 0 && (
195
+ <button
196
+ type="button"
197
+ onClick={() => onChange([])}
198
+ className="w-full px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted/30 border-t border-border"
199
+ data-testid={testId ? `${testId}-clear` : undefined}
200
+ >
201
+ Clear all
202
+ </button>
203
+ )}
204
+ </div>
205
+ )}
206
+ </div>
207
+ );
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Component
212
+ // ---------------------------------------------------------------------------
213
+
214
+ export interface LogFilterProps {
215
+ value: FilterValue;
216
+ onChange: (filters: FilterValue) => void;
217
+ rows?: OtelLogsRow[];
218
+ /** Controlled multi-select for services (empty = all). */
219
+ selectedServices?: string[];
220
+ onSelectedServicesChange?: (services: string[]) => void;
221
+ }
222
+
223
+ export function LogFilter({
224
+ value,
225
+ onChange,
226
+ rows = [],
227
+ selectedServices = [],
228
+ onSelectedServicesChange,
229
+ }: LogFilterProps) {
230
+ const [open, setOpen] = useState(false);
231
+ const [timeMode, setTimeMode] = useState<TimeMode>("lookback");
232
+ const [lookbackIdx, setLookbackIdx] = useState(-1);
233
+
234
+ // -- Derive filterable options from rows (sticky — accumulates over time) --
235
+ const svcRef = useRef(new Set<string>());
236
+ const sevRef = useRef(new Set<string>());
237
+ const scopeRef = useRef(new Set<string>());
238
+
239
+ const serviceNames = useMemo(() => {
240
+ for (const r of rows) if (r.ServiceName) svcRef.current.add(r.ServiceName);
241
+ return Array.from(svcRef.current).sort();
242
+ }, [rows]);
243
+
244
+ const severityTexts = useMemo(() => {
245
+ for (const r of rows)
246
+ if (r.SeverityText) sevRef.current.add(r.SeverityText);
247
+ return Array.from(sevRef.current).sort();
248
+ }, [rows]);
249
+
250
+ const scopeNames = useMemo(() => {
251
+ for (const r of rows) if (r.ScopeName) scopeRef.current.add(r.ScopeName);
252
+ return Array.from(scopeRef.current).sort();
253
+ }, [rows]);
254
+
255
+ // -- Debounced text fields ------------------------------------------------
256
+ const [bodyContains, setBodyContains] = useState(value.bodyContains ?? "");
257
+ const [traceId, setTraceId] = useState(value.traceId ?? "");
258
+ const [spanId, setSpanId] = useState(value.spanId ?? "");
259
+ const [logAttrsText, setLogAttrsText] = useState("");
260
+ const [resAttrsText, setResAttrsText] = useState("");
261
+ const [scopeAttrsText, setScopeAttrsText] = useState("");
262
+
263
+ const dBodyContains = useDebouncedValue(bodyContains, DEBOUNCE_MS);
264
+ const dTraceId = useDebouncedValue(traceId, DEBOUNCE_MS);
265
+ const dSpanId = useDebouncedValue(spanId, DEBOUNCE_MS);
266
+ const dLogAttrs = useDebouncedValue(logAttrsText, DEBOUNCE_MS);
267
+ const dResAttrs = useDebouncedValue(resAttrsText, DEBOUNCE_MS);
268
+ const dScopeAttrs = useDebouncedValue(scopeAttrsText, DEBOUNCE_MS);
269
+
270
+ // Prevent calling onChange on first render
271
+ const isFirstRender = useRef(true);
272
+
273
+ // -- Sync debounced text values to parent ---------------------------------
274
+ useEffect(() => {
275
+ if (isFirstRender.current) {
276
+ isFirstRender.current = false;
277
+ return;
278
+ }
279
+ const next: FilterValue = { ...value };
280
+
281
+ if (dBodyContains) next.bodyContains = dBodyContains;
282
+ else delete next.bodyContains;
283
+
284
+ if (dTraceId) next.traceId = dTraceId;
285
+ else delete next.traceId;
286
+
287
+ if (dSpanId) next.spanId = dSpanId;
288
+ else delete next.spanId;
289
+
290
+ const la = parseKeyValues(dLogAttrs);
291
+ if (la) next.logAttributes = la;
292
+ else delete next.logAttributes;
293
+
294
+ const ra = parseKeyValues(dResAttrs);
295
+ if (ra) next.resourceAttributes = ra;
296
+ else delete next.resourceAttributes;
297
+
298
+ const sa = parseKeyValues(dScopeAttrs);
299
+ if (sa) next.scopeAttributes = sa;
300
+ else delete next.scopeAttributes;
301
+
302
+ onChange(next);
303
+ }, [dBodyContains, dTraceId, dSpanId, dLogAttrs, dResAttrs, dScopeAttrs]);
304
+
305
+ // -- Immediate change helper (selects / numbers) --------------------------
306
+ const emitImmediate = useCallback(
307
+ (patch: Partial<FilterValue>) => {
308
+ const next: FilterValue = { ...value };
309
+ for (const [k, v] of Object.entries(patch)) {
310
+ if (v === undefined || v === "") {
311
+ delete (next as Record<string, unknown>)[k];
312
+ } else {
313
+ (next as Record<string, unknown>)[k] = v;
314
+ }
315
+ }
316
+ // Re-apply current debounced text fields so they don't get lost
317
+ if (dBodyContains) next.bodyContains = dBodyContains;
318
+ if (dTraceId) next.traceId = dTraceId;
319
+ if (dSpanId) next.spanId = dSpanId;
320
+ const la = parseKeyValues(dLogAttrs);
321
+ if (la) next.logAttributes = la;
322
+ const ra = parseKeyValues(dResAttrs);
323
+ if (ra) next.resourceAttributes = ra;
324
+ const sa = parseKeyValues(dScopeAttrs);
325
+ if (sa) next.scopeAttributes = sa;
326
+ onChange(next);
327
+ },
328
+ [
329
+ value,
330
+ onChange,
331
+ dBodyContains,
332
+ dTraceId,
333
+ dSpanId,
334
+ dLogAttrs,
335
+ dResAttrs,
336
+ dScopeAttrs,
337
+ ]
338
+ );
339
+
340
+ // Wire up the service handler to use emitImmediate via ref
341
+ const emitRef = useRef(emitImmediate);
342
+ emitRef.current = emitImmediate;
343
+ const handleServicesChangeSynced = useCallback(
344
+ (next: string[]) => {
345
+ onSelectedServicesChange?.(next);
346
+ if (next.length === 1) {
347
+ emitRef.current({ serviceName: next[0] });
348
+ } else {
349
+ emitRef.current({ serviceName: undefined });
350
+ }
351
+ },
352
+ [onSelectedServicesChange]
353
+ );
354
+
355
+ // -- Lookback handler -----------------------------------------------------
356
+ const handleLookback = useCallback(
357
+ (idx: number) => {
358
+ setLookbackIdx(idx);
359
+ if (idx < 0) {
360
+ emitImmediate({ timestampMin: undefined, timestampMax: undefined });
361
+ } else {
362
+ const opt = LOOKBACK_OPTIONS[idx];
363
+ if (opt) {
364
+ const tsMin = msToNs(Date.now() - opt.ms);
365
+ emitImmediate({ timestampMin: tsMin, timestampMax: undefined });
366
+ }
367
+ }
368
+ },
369
+ [emitImmediate]
370
+ );
371
+
372
+ // -- Absolute time handlers -----------------------------------------------
373
+ const handleAbsoluteMin = useCallback(
374
+ (dtStr: string) => {
375
+ if (!dtStr) {
376
+ emitImmediate({ timestampMin: undefined });
377
+ return;
378
+ }
379
+ const ms = new Date(dtStr).getTime();
380
+ emitImmediate({ timestampMin: msToNs(ms) });
381
+ },
382
+ [emitImmediate]
383
+ );
384
+
385
+ const handleAbsoluteMax = useCallback(
386
+ (dtStr: string) => {
387
+ if (!dtStr) {
388
+ emitImmediate({ timestampMax: undefined });
389
+ return;
390
+ }
391
+ const ms = new Date(dtStr).getTime();
392
+ emitImmediate({ timestampMax: msToNs(ms) });
393
+ },
394
+ [emitImmediate]
395
+ );
396
+
397
+ // -- Time mode switch -----------------------------------------------------
398
+ const switchTimeMode = useCallback(
399
+ (mode: TimeMode) => {
400
+ setTimeMode(mode);
401
+ setLookbackIdx(-1);
402
+ emitImmediate({ timestampMin: undefined, timestampMax: undefined });
403
+ },
404
+ [emitImmediate]
405
+ );
406
+
407
+ // -- Filter summary for collapsed state -----------------------------------
408
+ const summary = buildFilterSummary(value, selectedServices);
409
+
410
+ return (
411
+ <div className="border border-border rounded-lg" data-testid="log-filter">
412
+ <button
413
+ onClick={() => setOpen((v) => !v)}
414
+ className="w-full flex items-center justify-between px-4 py-2.5 text-sm font-medium text-foreground hover:bg-muted/30 transition-colors"
415
+ data-testid="log-filter-toggle"
416
+ >
417
+ <span className="flex items-center gap-2">
418
+ <span>
419
+ <span className="underline underline-offset-4">F</span>ilters
420
+ </span>
421
+ {!open && summary && (
422
+ <span
423
+ className="text-xs text-muted-foreground truncate max-w-md"
424
+ data-testid="filter-summary"
425
+ >
426
+ {summary}
427
+ </span>
428
+ )}
429
+ </span>
430
+ <span className="text-muted-foreground text-xs">
431
+ {open ? "▲" : "▼"}
432
+ </span>
433
+ </button>
434
+
435
+ {open && (
436
+ <div className="px-4 pb-4 pt-1 border-t border-border space-y-3">
437
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
438
+ {/* Service Name — multi-select */}
439
+ <div className="space-y-1">
440
+ <span className={LABEL_CLS}>Service</span>
441
+ <MultiSelect
442
+ options={serviceNames}
443
+ selected={selectedServices}
444
+ onChange={handleServicesChangeSynced}
445
+ testId="filter-serviceName"
446
+ />
447
+ </div>
448
+
449
+ {/* Severity */}
450
+ <label className="space-y-1">
451
+ <span className={LABEL_CLS}>Severity</span>
452
+ <select
453
+ value={value.severityText ?? ""}
454
+ onChange={(e) =>
455
+ emitImmediate({
456
+ severityText: e.target.value || undefined,
457
+ })
458
+ }
459
+ className={INPUT_CLS}
460
+ data-testid="filter-severityText"
461
+ >
462
+ <option value="">All</option>
463
+ {severityTexts.map((s) => (
464
+ <option key={s} value={s}>
465
+ {s}
466
+ </option>
467
+ ))}
468
+ </select>
469
+ </label>
470
+
471
+ {/* Body Contains */}
472
+ <label className="space-y-1">
473
+ <span className={LABEL_CLS}>Body contains</span>
474
+ <input
475
+ type="text"
476
+ placeholder="Search log body... (/)"
477
+ value={bodyContains}
478
+ onChange={(e) => setBodyContains(e.target.value)}
479
+ className={INPUT_CLS}
480
+ data-testid="filter-bodyContains"
481
+ />
482
+ </label>
483
+
484
+ {/* Sort Order */}
485
+ <label className="space-y-1">
486
+ <span className={LABEL_CLS}>Sort</span>
487
+ <select
488
+ value={value.sortOrder ?? "DESC"}
489
+ onChange={(e) =>
490
+ emitImmediate({
491
+ sortOrder: e.target.value as "ASC" | "DESC",
492
+ })
493
+ }
494
+ className={INPUT_CLS}
495
+ data-testid="filter-sortOrder"
496
+ >
497
+ <option value="DESC">Newest first</option>
498
+ <option value="ASC">Oldest first</option>
499
+ </select>
500
+ </label>
501
+
502
+ {/* Limit */}
503
+ <label className="space-y-1">
504
+ <span className={LABEL_CLS}>Limit</span>
505
+ <input
506
+ type="number"
507
+ min={1}
508
+ max={1000}
509
+ value={value.limit ?? ""}
510
+ onChange={(e) => {
511
+ const n = Number(e.target.value);
512
+ emitImmediate({
513
+ limit: n >= 1 && n <= 1000 ? n : undefined,
514
+ });
515
+ }}
516
+ className={INPUT_CLS}
517
+ data-testid="filter-limit"
518
+ />
519
+ </label>
520
+
521
+ {/* Trace ID */}
522
+ <label className="space-y-1">
523
+ <span className={LABEL_CLS}>Trace ID</span>
524
+ <input
525
+ type="text"
526
+ placeholder="Trace ID"
527
+ value={traceId}
528
+ onChange={(e) => setTraceId(e.target.value)}
529
+ className={INPUT_CLS}
530
+ data-testid="filter-traceId"
531
+ />
532
+ </label>
533
+
534
+ {/* Span ID */}
535
+ <label className="space-y-1">
536
+ <span className={LABEL_CLS}>Span ID</span>
537
+ <input
538
+ type="text"
539
+ placeholder="Span ID"
540
+ value={spanId}
541
+ onChange={(e) => setSpanId(e.target.value)}
542
+ className={INPUT_CLS}
543
+ data-testid="filter-spanId"
544
+ />
545
+ </label>
546
+
547
+ {/* Scope Name */}
548
+ <label className="space-y-1">
549
+ <span className={LABEL_CLS}>Scope</span>
550
+ <select
551
+ value={value.scopeName ?? ""}
552
+ onChange={(e) =>
553
+ emitImmediate({
554
+ scopeName: e.target.value || undefined,
555
+ })
556
+ }
557
+ className={INPUT_CLS}
558
+ data-testid="filter-scopeName"
559
+ >
560
+ <option value="">All</option>
561
+ {scopeNames.map((s) => (
562
+ <option key={s} value={s}>
563
+ {s}
564
+ </option>
565
+ ))}
566
+ </select>
567
+ </label>
568
+
569
+ {/* Log Attributes */}
570
+ <label className="space-y-1">
571
+ <span className={LABEL_CLS}>Log attributes</span>
572
+ <input
573
+ type="text"
574
+ placeholder="key1=val1, key2=val2"
575
+ value={logAttrsText}
576
+ onChange={(e) => setLogAttrsText(e.target.value)}
577
+ className={INPUT_CLS}
578
+ data-testid="filter-logAttributes"
579
+ />
580
+ </label>
581
+
582
+ {/* Resource Attributes */}
583
+ <label className="space-y-1">
584
+ <span className={LABEL_CLS}>Resource attributes</span>
585
+ <input
586
+ type="text"
587
+ placeholder="key1=val1, key2=val2"
588
+ value={resAttrsText}
589
+ onChange={(e) => setResAttrsText(e.target.value)}
590
+ className={INPUT_CLS}
591
+ data-testid="filter-resourceAttributes"
592
+ />
593
+ </label>
594
+
595
+ {/* Scope Attributes */}
596
+ <label className="space-y-1">
597
+ <span className={LABEL_CLS}>Scope attributes</span>
598
+ <input
599
+ type="text"
600
+ placeholder="key1=val1, key2=val2"
601
+ value={scopeAttrsText}
602
+ onChange={(e) => setScopeAttrsText(e.target.value)}
603
+ className={INPUT_CLS}
604
+ data-testid="filter-scopeAttributes"
605
+ />
606
+ </label>
607
+ </div>
608
+
609
+ {/* Time range */}
610
+ <div className="space-y-2">
611
+ <div className="flex items-center gap-2">
612
+ <span className={LABEL_CLS}>Time range</span>
613
+ <div className="flex rounded-md border border-border overflow-hidden text-xs">
614
+ <button
615
+ className={`px-2 py-1 ${timeMode === "lookback" ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/30"}`}
616
+ onClick={() => switchTimeMode("lookback")}
617
+ data-testid="time-mode-lookback"
618
+ >
619
+ Lookback
620
+ </button>
621
+ <button
622
+ className={`px-2 py-1 ${timeMode === "absolute" ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/30"}`}
623
+ onClick={() => switchTimeMode("absolute")}
624
+ data-testid="time-mode-absolute"
625
+ >
626
+ Absolute
627
+ </button>
628
+ </div>
629
+ </div>
630
+
631
+ {timeMode === "lookback" ? (
632
+ <select
633
+ value={lookbackIdx}
634
+ onChange={(e) => handleLookback(Number(e.target.value))}
635
+ className={`${INPUT_CLS} max-w-xs`}
636
+ data-testid="filter-lookback"
637
+ >
638
+ <option value={-1}>All time</option>
639
+ {LOOKBACK_OPTIONS.map((opt, i) => (
640
+ <option key={i} value={i}>
641
+ {opt.label}
642
+ </option>
643
+ ))}
644
+ </select>
645
+ ) : (
646
+ <div className="flex items-center gap-2">
647
+ <label className="space-y-1 flex-1">
648
+ <span className={LABEL_CLS}>From</span>
649
+ <input
650
+ type="datetime-local"
651
+ value={nsToDatetimeLocal(value.timestampMin)}
652
+ onChange={(e) => handleAbsoluteMin(e.target.value)}
653
+ className={INPUT_CLS}
654
+ data-testid="filter-timestampMin"
655
+ />
656
+ </label>
657
+ <label className="space-y-1 flex-1">
658
+ <span className={LABEL_CLS}>To</span>
659
+ <input
660
+ type="datetime-local"
661
+ value={nsToDatetimeLocal(value.timestampMax)}
662
+ onChange={(e) => handleAbsoluteMax(e.target.value)}
663
+ className={INPUT_CLS}
664
+ data-testid="filter-timestampMax"
665
+ />
666
+ </label>
667
+ </div>
668
+ )}
669
+ </div>
670
+ </div>
671
+ )}
672
+ </div>
673
+ );
674
+ }