@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.
- 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 +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,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
|
+
}
|