@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.
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 +825 -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,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: [] } };