@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,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogTimeline - Accepts OtelLogsRow[] and renders log visualization.
|
|
3
|
+
* Transforms denormalized rows to LogEntry[] internally.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMemo, useState, useRef, useCallback, useEffect } from "react";
|
|
7
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
8
|
+
import type { denormalizedSignals } from "@kopai/core";
|
|
9
|
+
import type { LogEntry } from "../types.js";
|
|
10
|
+
import { LogRow } from "./LogRow.js";
|
|
11
|
+
import { LogDetailPane } from "./LogDetailPane/index.js";
|
|
12
|
+
import { LoadingSkeleton } from "../shared/LoadingSkeleton.js";
|
|
13
|
+
import { useRegisterShortcuts } from "../../KeyboardShortcuts/index.js";
|
|
14
|
+
import { LOG_VIEWER_SHORTCUTS } from "./shortcuts.js";
|
|
15
|
+
|
|
16
|
+
type OtelLogsRow = denormalizedSignals.OtelLogsRow;
|
|
17
|
+
|
|
18
|
+
const LOG_ROW_HEIGHT = 44;
|
|
19
|
+
const OVERSCAN_COUNT = 20;
|
|
20
|
+
const DEFAULT_MAX_LOGS = 1000;
|
|
21
|
+
const BOTTOM_THRESHOLD_PX = 50;
|
|
22
|
+
|
|
23
|
+
const VIRTUAL_ROW_STYLE_BASE = {
|
|
24
|
+
position: "absolute" as const,
|
|
25
|
+
top: 0,
|
|
26
|
+
left: 0,
|
|
27
|
+
width: "100%",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface LogTimelineProps {
|
|
31
|
+
rows: OtelLogsRow[];
|
|
32
|
+
onLogClick?: (log: LogEntry) => void;
|
|
33
|
+
onTraceLinkClick?: (traceId: string, spanId: string) => void;
|
|
34
|
+
selectedLogId?: string;
|
|
35
|
+
isLoading?: boolean;
|
|
36
|
+
error?: Error;
|
|
37
|
+
streaming?: boolean;
|
|
38
|
+
maxLogs?: number;
|
|
39
|
+
searchText?: string;
|
|
40
|
+
onAtBottomChange?: (isAtBottom: boolean) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function simpleHash(str: string): string {
|
|
44
|
+
let hash = 0;
|
|
45
|
+
for (let i = 0; i < str.length; i++) {
|
|
46
|
+
const char = str.charCodeAt(i);
|
|
47
|
+
hash = (hash << 5) - hash + char;
|
|
48
|
+
hash = hash & hash;
|
|
49
|
+
}
|
|
50
|
+
return Math.abs(hash).toString(36);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getSeverityText(
|
|
54
|
+
severityNumber: number | undefined,
|
|
55
|
+
severityText: string | undefined
|
|
56
|
+
): string {
|
|
57
|
+
if (severityText) return severityText;
|
|
58
|
+
const n = severityNumber ?? 0;
|
|
59
|
+
if (n >= 21) return "FATAL";
|
|
60
|
+
if (n >= 17) return "ERROR";
|
|
61
|
+
if (n >= 13) return "WARN";
|
|
62
|
+
if (n >= 9) return "INFO";
|
|
63
|
+
if (n >= 5) return "DEBUG";
|
|
64
|
+
if (n >= 1) return "TRACE";
|
|
65
|
+
return "UNSPECIFIED";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Transform OtelLogsRow[] to LogEntry[] */
|
|
69
|
+
function buildLogs(rows: OtelLogsRow[]): LogEntry[] {
|
|
70
|
+
return rows
|
|
71
|
+
.map((row) => {
|
|
72
|
+
const timeUnixMs = parseInt(row.Timestamp, 10) / 1e6;
|
|
73
|
+
const body = row.Body ?? "";
|
|
74
|
+
const severityText = getSeverityText(
|
|
75
|
+
row.SeverityNumber,
|
|
76
|
+
row.SeverityText
|
|
77
|
+
);
|
|
78
|
+
const logId = `${row.Timestamp}-${row.ServiceName ?? "unknown"}-${simpleHash(body)}`;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
logId,
|
|
82
|
+
timeUnixMs,
|
|
83
|
+
body,
|
|
84
|
+
severityText,
|
|
85
|
+
severityNumber: row.SeverityNumber ?? 0,
|
|
86
|
+
serviceName: row.ServiceName ?? "unknown",
|
|
87
|
+
traceId: row.TraceId,
|
|
88
|
+
spanId: row.SpanId,
|
|
89
|
+
attributes: row.LogAttributes ?? {},
|
|
90
|
+
resourceAttributes: row.ResourceAttributes ?? {},
|
|
91
|
+
scopeName: row.ScopeName,
|
|
92
|
+
};
|
|
93
|
+
})
|
|
94
|
+
.sort((a, b) => a.timeUnixMs - b.timeUnixMs);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function LogTimeline({
|
|
98
|
+
rows,
|
|
99
|
+
onLogClick,
|
|
100
|
+
onTraceLinkClick,
|
|
101
|
+
selectedLogId: externalSelectedLogId,
|
|
102
|
+
isLoading,
|
|
103
|
+
error,
|
|
104
|
+
streaming = false,
|
|
105
|
+
maxLogs = DEFAULT_MAX_LOGS,
|
|
106
|
+
searchText = "",
|
|
107
|
+
onAtBottomChange,
|
|
108
|
+
}: LogTimelineProps) {
|
|
109
|
+
useRegisterShortcuts("log-viewer", LOG_VIEWER_SHORTCUTS);
|
|
110
|
+
|
|
111
|
+
const [internalSelectedLogId, setInternalSelectedLogId] = useState<
|
|
112
|
+
string | null
|
|
113
|
+
>(null);
|
|
114
|
+
const [isDetailPaneOpen, setIsDetailPaneOpen] = useState(false);
|
|
115
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
116
|
+
const [wordWrap, setWordWrap] = useState(true);
|
|
117
|
+
const [relativeTime, setRelativeTime] = useState(false);
|
|
118
|
+
const selectedLogId = externalSelectedLogId ?? internalSelectedLogId;
|
|
119
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
120
|
+
const announcementRef = useRef<HTMLDivElement>(null);
|
|
121
|
+
const wasAtBottomRef = useRef(true);
|
|
122
|
+
const hasScrolledToInitialRef = useRef(false);
|
|
123
|
+
|
|
124
|
+
const allLogs = useMemo(() => buildLogs(rows), [rows]);
|
|
125
|
+
|
|
126
|
+
const boundedLogs = useMemo(() => {
|
|
127
|
+
if (streaming && allLogs.length > maxLogs)
|
|
128
|
+
return allLogs.slice(allLogs.length - maxLogs);
|
|
129
|
+
return allLogs;
|
|
130
|
+
}, [allLogs, streaming, maxLogs]);
|
|
131
|
+
|
|
132
|
+
const selectedLog = useMemo(() => {
|
|
133
|
+
return boundedLogs.find((log) => log.logId === selectedLogId) ?? null;
|
|
134
|
+
}, [boundedLogs, selectedLogId]);
|
|
135
|
+
|
|
136
|
+
const referenceTimeMs = useMemo(() => {
|
|
137
|
+
if (selectedLog) return selectedLog.timeUnixMs;
|
|
138
|
+
const first = boundedLogs[0];
|
|
139
|
+
return first ? first.timeUnixMs : 0;
|
|
140
|
+
}, [selectedLog, boundedLogs]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (externalSelectedLogId) setIsDetailPaneOpen(true);
|
|
144
|
+
}, [externalSelectedLogId]);
|
|
145
|
+
|
|
146
|
+
const checkIfAtBottom = useCallback(() => {
|
|
147
|
+
if (!scrollRef.current) return true;
|
|
148
|
+
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
|
149
|
+
if (scrollHeight <= clientHeight) return true;
|
|
150
|
+
return scrollHeight - scrollTop - clientHeight < BOTTOM_THRESHOLD_PX;
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const prevAtBottomRef = useRef(true);
|
|
154
|
+
|
|
155
|
+
const handleScroll = useCallback(() => {
|
|
156
|
+
const atBottom = checkIfAtBottom();
|
|
157
|
+
wasAtBottomRef.current = atBottom;
|
|
158
|
+
setIsAtBottom(atBottom);
|
|
159
|
+
if (atBottom !== prevAtBottomRef.current) {
|
|
160
|
+
prevAtBottomRef.current = atBottom;
|
|
161
|
+
onAtBottomChange?.(atBottom);
|
|
162
|
+
}
|
|
163
|
+
}, [checkIfAtBottom, onAtBottomChange]);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const atBottom = checkIfAtBottom();
|
|
167
|
+
wasAtBottomRef.current = atBottom;
|
|
168
|
+
setIsAtBottom(atBottom);
|
|
169
|
+
}, [boundedLogs.length, checkIfAtBottom]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (streaming && wasAtBottomRef.current && scrollRef.current) {
|
|
173
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
174
|
+
}
|
|
175
|
+
}, [boundedLogs, streaming]);
|
|
176
|
+
|
|
177
|
+
const virtualizer = useVirtualizer({
|
|
178
|
+
count: boundedLogs.length,
|
|
179
|
+
getScrollElement: () => scrollRef.current,
|
|
180
|
+
estimateSize: () => LOG_ROW_HEIGHT,
|
|
181
|
+
overscan: OVERSCAN_COUNT,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Scroll to externally-selected log on initial data load
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (hasScrolledToInitialRef.current) return;
|
|
187
|
+
if (!externalSelectedLogId || boundedLogs.length === 0) return;
|
|
188
|
+
const idx = boundedLogs.findIndex((l) => l.logId === externalSelectedLogId);
|
|
189
|
+
if (idx === -1) return;
|
|
190
|
+
hasScrolledToInitialRef.current = true;
|
|
191
|
+
virtualizer.scrollToIndex(idx, { align: "center" });
|
|
192
|
+
}, [externalSelectedLogId, boundedLogs, virtualizer]);
|
|
193
|
+
|
|
194
|
+
const handleLogClick = useCallback(
|
|
195
|
+
(log: LogEntry) => {
|
|
196
|
+
setInternalSelectedLogId(log.logId);
|
|
197
|
+
setIsDetailPaneOpen(true);
|
|
198
|
+
onLogClick?.(log);
|
|
199
|
+
if (announcementRef.current) {
|
|
200
|
+
announcementRef.current.textContent = `Selected log from ${log.serviceName}: ${log.body.slice(0, 100)}`;
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
[onLogClick]
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const logClickHandlers = useMemo(() => {
|
|
207
|
+
const handlers = new Map<string, () => void>();
|
|
208
|
+
boundedLogs.forEach((log) => {
|
|
209
|
+
handlers.set(log.logId, () => handleLogClick(log));
|
|
210
|
+
});
|
|
211
|
+
return handlers;
|
|
212
|
+
}, [boundedLogs, handleLogClick]);
|
|
213
|
+
|
|
214
|
+
const handleCloseDetailPane = useCallback(() => {
|
|
215
|
+
setIsDetailPaneOpen(false);
|
|
216
|
+
setInternalSelectedLogId(null);
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
const handleScrollToBottom = useCallback(() => {
|
|
220
|
+
if (scrollRef.current) {
|
|
221
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
222
|
+
wasAtBottomRef.current = true;
|
|
223
|
+
setIsAtBottom(true);
|
|
224
|
+
if (!prevAtBottomRef.current) {
|
|
225
|
+
prevAtBottomRef.current = true;
|
|
226
|
+
onAtBottomChange?.(true);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}, [onAtBottomChange]);
|
|
230
|
+
|
|
231
|
+
const navigateUp = useCallback(() => {
|
|
232
|
+
const idx = boundedLogs.findIndex((l) => l.logId === selectedLogId);
|
|
233
|
+
if (idx > 0) {
|
|
234
|
+
const prev = boundedLogs[idx - 1];
|
|
235
|
+
if (prev) {
|
|
236
|
+
handleLogClick(prev);
|
|
237
|
+
virtualizer.scrollToIndex(idx - 1, { align: "auto" });
|
|
238
|
+
}
|
|
239
|
+
} else if (idx === -1 && boundedLogs.length > 0) {
|
|
240
|
+
const targetIdx = boundedLogs.length - 1;
|
|
241
|
+
const last = boundedLogs[targetIdx];
|
|
242
|
+
if (last) {
|
|
243
|
+
handleLogClick(last);
|
|
244
|
+
virtualizer.scrollToIndex(targetIdx, { align: "auto" });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}, [boundedLogs, selectedLogId, handleLogClick, virtualizer]);
|
|
248
|
+
|
|
249
|
+
const navigateDown = useCallback(() => {
|
|
250
|
+
const idx = boundedLogs.findIndex((l) => l.logId === selectedLogId);
|
|
251
|
+
if (idx >= 0 && idx < boundedLogs.length - 1) {
|
|
252
|
+
const next = boundedLogs[idx + 1];
|
|
253
|
+
if (next) {
|
|
254
|
+
handleLogClick(next);
|
|
255
|
+
virtualizer.scrollToIndex(idx + 1, { align: "auto" });
|
|
256
|
+
}
|
|
257
|
+
} else if (idx === -1 && boundedLogs.length > 0) {
|
|
258
|
+
const first = boundedLogs[0];
|
|
259
|
+
if (first) {
|
|
260
|
+
handleLogClick(first);
|
|
261
|
+
virtualizer.scrollToIndex(0, { align: "auto" });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}, [boundedLogs, selectedLogId, handleLogClick, virtualizer]);
|
|
265
|
+
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
268
|
+
const isFormField =
|
|
269
|
+
e.target instanceof HTMLInputElement ||
|
|
270
|
+
e.target instanceof HTMLTextAreaElement ||
|
|
271
|
+
e.target instanceof HTMLSelectElement;
|
|
272
|
+
if (isFormField && e.key === "Escape") {
|
|
273
|
+
(e.target as HTMLElement).blur();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (isFormField) return;
|
|
277
|
+
switch (e.key) {
|
|
278
|
+
case "ArrowUp":
|
|
279
|
+
case "k":
|
|
280
|
+
case "K":
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
navigateUp();
|
|
283
|
+
break;
|
|
284
|
+
case "ArrowDown":
|
|
285
|
+
case "j":
|
|
286
|
+
case "J":
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
navigateDown();
|
|
289
|
+
break;
|
|
290
|
+
case "Escape":
|
|
291
|
+
if (isDetailPaneOpen) {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
handleCloseDetailPane();
|
|
294
|
+
} else {
|
|
295
|
+
const panel = document.querySelector('[data-testid="log-filter"]');
|
|
296
|
+
const toggle = panel?.querySelector<HTMLButtonElement>(
|
|
297
|
+
'[data-testid="log-filter-toggle"]'
|
|
298
|
+
);
|
|
299
|
+
if (toggle && panel?.querySelector(".border-t")) {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
toggle.click();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
case "g":
|
|
306
|
+
case "G":
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
handleScrollToBottom();
|
|
309
|
+
break;
|
|
310
|
+
case "/": {
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
const input = document.querySelector<HTMLInputElement>(
|
|
313
|
+
'[data-testid="filter-bodyContains"]'
|
|
314
|
+
);
|
|
315
|
+
if (input) {
|
|
316
|
+
// Ensure filter panel is open first
|
|
317
|
+
const panel = document.querySelector('[data-testid="log-filter"]');
|
|
318
|
+
const toggle = panel?.querySelector<HTMLButtonElement>(
|
|
319
|
+
'[data-testid="log-filter-toggle"]'
|
|
320
|
+
);
|
|
321
|
+
// Check if panel content is visible (has more than just the toggle button)
|
|
322
|
+
if (toggle && !panel?.querySelector(".border-t")) {
|
|
323
|
+
toggle.click();
|
|
324
|
+
// Focus after panel opens
|
|
325
|
+
requestAnimationFrame(() => {
|
|
326
|
+
document
|
|
327
|
+
.querySelector<HTMLInputElement>(
|
|
328
|
+
'[data-testid="filter-bodyContains"]'
|
|
329
|
+
)
|
|
330
|
+
?.focus();
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
input.focus();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case "f":
|
|
339
|
+
case "F": {
|
|
340
|
+
e.preventDefault();
|
|
341
|
+
const toggle = document.querySelector<HTMLButtonElement>(
|
|
342
|
+
'[data-testid="log-filter-toggle"]'
|
|
343
|
+
);
|
|
344
|
+
toggle?.click();
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case "Enter": {
|
|
348
|
+
if (selectedLogId && !isDetailPaneOpen) {
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
const log = boundedLogs.find((l) => l.logId === selectedLogId);
|
|
351
|
+
if (log) handleLogClick(log);
|
|
352
|
+
}
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case "Home": {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
if (boundedLogs.length > 0) {
|
|
358
|
+
const first = boundedLogs[0];
|
|
359
|
+
if (first) {
|
|
360
|
+
handleLogClick(first);
|
|
361
|
+
virtualizer.scrollToIndex(0);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "c":
|
|
367
|
+
case "C": {
|
|
368
|
+
if (e.ctrlKey || e.metaKey) break;
|
|
369
|
+
e.preventDefault();
|
|
370
|
+
if (selectedLog) {
|
|
371
|
+
navigator.clipboard.writeText(selectedLog.body).catch(() => {});
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case "w":
|
|
376
|
+
case "W":
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
setWordWrap((v) => !v);
|
|
379
|
+
break;
|
|
380
|
+
case "t":
|
|
381
|
+
case "T":
|
|
382
|
+
e.preventDefault();
|
|
383
|
+
setRelativeTime((v) => !v);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
388
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
389
|
+
}, [
|
|
390
|
+
boundedLogs,
|
|
391
|
+
selectedLogId,
|
|
392
|
+
selectedLog,
|
|
393
|
+
navigateUp,
|
|
394
|
+
navigateDown,
|
|
395
|
+
handleLogClick,
|
|
396
|
+
handleCloseDetailPane,
|
|
397
|
+
handleScrollToBottom,
|
|
398
|
+
isDetailPaneOpen,
|
|
399
|
+
virtualizer,
|
|
400
|
+
]);
|
|
401
|
+
|
|
402
|
+
if (isLoading && !boundedLogs.length) return <LoadingSkeleton />;
|
|
403
|
+
|
|
404
|
+
if (error) {
|
|
405
|
+
return (
|
|
406
|
+
<div className="flex items-center justify-center h-full bg-background">
|
|
407
|
+
<div className="text-center p-6">
|
|
408
|
+
<div className="text-red-600 dark:text-red-400 mb-2">
|
|
409
|
+
Failed to load logs
|
|
410
|
+
</div>
|
|
411
|
+
<div className="text-sm text-muted-foreground">{error.message}</div>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (boundedLogs.length === 0) {
|
|
418
|
+
return (
|
|
419
|
+
<div className="flex items-center justify-center h-full bg-background">
|
|
420
|
+
<div className="text-center p-6">
|
|
421
|
+
<div className="text-muted-foreground mb-2">No logs</div>
|
|
422
|
+
<div className="text-sm text-muted-foreground">
|
|
423
|
+
{streaming ? "Waiting for logs..." : "No log data available"}
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div className="flex flex-col h-full min-h-0 bg-background">
|
|
432
|
+
<div
|
|
433
|
+
ref={announcementRef}
|
|
434
|
+
className="sr-only"
|
|
435
|
+
role="status"
|
|
436
|
+
aria-live="polite"
|
|
437
|
+
aria-atomic="true"
|
|
438
|
+
/>
|
|
439
|
+
<div className="flex flex-1 min-h-0">
|
|
440
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
441
|
+
{/* Header */}
|
|
442
|
+
<div className="border-b border-border px-4 py-3">
|
|
443
|
+
<div className="flex items-center justify-between">
|
|
444
|
+
<div className="flex items-center gap-2">
|
|
445
|
+
<div className="flex items-center gap-1.5 px-2 py-1 bg-muted rounded-md text-sm font-medium text-muted-foreground">
|
|
446
|
+
<svg
|
|
447
|
+
className="w-4 h-4"
|
|
448
|
+
fill="none"
|
|
449
|
+
stroke="currentColor"
|
|
450
|
+
viewBox="0 0 24 24"
|
|
451
|
+
>
|
|
452
|
+
<path
|
|
453
|
+
strokeLinecap="round"
|
|
454
|
+
strokeLinejoin="round"
|
|
455
|
+
strokeWidth={2}
|
|
456
|
+
d="M4 6h16M4 12h16M4 18h7"
|
|
457
|
+
/>
|
|
458
|
+
</svg>
|
|
459
|
+
{boundedLogs.length}{" "}
|
|
460
|
+
{boundedLogs.length === 1 ? "log" : "logs"}
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
|
|
466
|
+
{/* Virtual Scroll */}
|
|
467
|
+
<div className="flex-1 relative">
|
|
468
|
+
<div
|
|
469
|
+
ref={scrollRef}
|
|
470
|
+
className="absolute inset-0 overflow-auto"
|
|
471
|
+
onScroll={handleScroll}
|
|
472
|
+
>
|
|
473
|
+
<div
|
|
474
|
+
style={{
|
|
475
|
+
height: `${virtualizer.getTotalSize()}px`,
|
|
476
|
+
width: "100%",
|
|
477
|
+
position: "relative",
|
|
478
|
+
}}
|
|
479
|
+
>
|
|
480
|
+
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
481
|
+
const log = boundedLogs[virtualRow.index];
|
|
482
|
+
if (!log) return null;
|
|
483
|
+
return (
|
|
484
|
+
<div
|
|
485
|
+
key={virtualRow.index}
|
|
486
|
+
style={{
|
|
487
|
+
...VIRTUAL_ROW_STYLE_BASE,
|
|
488
|
+
height: LOG_ROW_HEIGHT,
|
|
489
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
490
|
+
}}
|
|
491
|
+
>
|
|
492
|
+
<LogRow
|
|
493
|
+
log={log}
|
|
494
|
+
isSelected={log.logId === selectedLogId}
|
|
495
|
+
onClick={logClickHandlers.get(log.logId)!}
|
|
496
|
+
searchText={searchText}
|
|
497
|
+
relativeTime={relativeTime}
|
|
498
|
+
referenceTimeMs={referenceTimeMs}
|
|
499
|
+
/>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
})}
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
{!isAtBottom && (
|
|
506
|
+
<button
|
|
507
|
+
onClick={handleScrollToBottom}
|
|
508
|
+
className="absolute bottom-4 right-4 px-3 py-1.5 text-xs font-medium rounded-md bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg transition-colors z-10"
|
|
509
|
+
aria-label="Scroll to bottom"
|
|
510
|
+
>
|
|
511
|
+
<span className="flex items-center gap-1">
|
|
512
|
+
<svg
|
|
513
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
514
|
+
viewBox="0 0 16 16"
|
|
515
|
+
fill="currentColor"
|
|
516
|
+
className="w-3 h-3"
|
|
517
|
+
>
|
|
518
|
+
<path
|
|
519
|
+
fillRule="evenodd"
|
|
520
|
+
d="M8 2a.75.75 0 0 1 .75.75v8.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.22 3.22V2.75A.75.75 0 0 1 8 2Z"
|
|
521
|
+
clipRule="evenodd"
|
|
522
|
+
/>
|
|
523
|
+
</svg>
|
|
524
|
+
(<span className="underline underline-offset-4">G</span>)
|
|
525
|
+
</span>
|
|
526
|
+
</button>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
{isDetailPaneOpen && selectedLog && (
|
|
532
|
+
<LogDetailPane
|
|
533
|
+
log={selectedLog}
|
|
534
|
+
onClose={handleCloseDetailPane}
|
|
535
|
+
onTraceLinkClick={onTraceLinkClick}
|
|
536
|
+
wordWrap={wordWrap}
|
|
537
|
+
/>
|
|
538
|
+
)}
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ShortcutGroup } from "../../KeyboardShortcuts/types.js";
|
|
2
|
+
|
|
3
|
+
export const LOG_VIEWER_SHORTCUTS: ShortcutGroup = {
|
|
4
|
+
name: "Log Viewer",
|
|
5
|
+
shortcuts: [
|
|
6
|
+
{ keys: ["↑/K"], description: "Previous log" },
|
|
7
|
+
{ keys: ["↓/J"], description: "Next log" },
|
|
8
|
+
{ keys: ["G"], description: "Scroll to bottom" },
|
|
9
|
+
{ keys: ["Home"], description: "First log" },
|
|
10
|
+
{ keys: ["/"], description: "Focus search" },
|
|
11
|
+
{ keys: ["F"], description: "Toggle filters" },
|
|
12
|
+
{ keys: ["Enter"], description: "Open log detail" },
|
|
13
|
+
{ keys: ["C"], description: "Copy log body" },
|
|
14
|
+
{ keys: ["W"], description: "Toggle word wrap" },
|
|
15
|
+
{ keys: ["T"], description: "Toggle timestamps" },
|
|
16
|
+
{ keys: ["Esc"], description: "Close detail/filter pane" },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MetricHistogram } from "./index.js";
|
|
3
|
+
import { mockHistogramRows } from "../__fixtures__/metrics.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MetricHistogram> = {
|
|
6
|
+
title: "Observability/MetricHistogram",
|
|
7
|
+
component: MetricHistogram,
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof MetricHistogram>;
|
|
11
|
+
|
|
12
|
+
export const Default: Story = { args: { rows: mockHistogramRows } };
|
|
13
|
+
export const Loading: Story = { args: { rows: [], isLoading: true } };
|
|
14
|
+
export const Error: Story = {
|
|
15
|
+
args: {
|
|
16
|
+
rows: [],
|
|
17
|
+
error: new globalThis.Error("Failed to fetch histogram data"),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export const Empty: Story = { args: { rows: [] } };
|