Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './log_viewer.module.css';
3 :
4 : import { List, useListCallbackRef } from 'react-window';
5 : import type { RowComponentProps } from 'react-window';
6 : import { XIcon, DownloadIcon } from '../foundations/symbol_icon.js';
7 :
8 : import { getLogLevelName, LogRecord } from '../../platform/logger/log_buffer.js';
9 : import { pollLogVersion, useLogger } from '../../platform/logger/logger_provider.js';
10 : import { observeSize } from '../foundations/size_observer.js';
11 : import { ButtonVariant, IconButton } from '../foundations/button.js';
12 : import { AnchorAlignment, AnchorSide } from '../foundations/anchored_position.js';
13 : import { useKeyEvents } from '../../utils/key_events.js';
14 : import { LogJsonModal } from './log_json_modal.js';
15 : import { useFileDownloader } from '../../platform/file/file_downloader_hook.js';
16 : import { VerticalScrollShadows } from '../foundations/vertical_scroll_shadows.js';
17 :
18 : export const ROW_HEIGHT = 32;
19 :
20 : /// Formatting a timestamp with toLocaleTimeString creates a new formatter for every row
21 1 : const TIMESTAMP_FORMAT = new Intl.DateTimeFormat('en-GB', {
22 1 : hour: 'numeric',
23 1 : minute: 'numeric',
24 1 : second: 'numeric',
25 1 : hour12: false,
26 1 : });
27 :
28 1 : export function formatLogTimestamp(timestamp: number): string {
29 1 : return TIMESTAMP_FORMAT.format(timestamp);
30 1 : }
31 :
32 : export interface LogRowProps {
33 : logger: ReturnType<typeof useLogger>;
34 : showJsonRecord: (rowIndex: number) => void;
35 : selectedRecordIndex: number;
36 : filteredLogs?: LogRecord[];
37 : }
38 :
39 1 : export const LogRow = (props: RowComponentProps<LogRowProps>) => {
40 1 : const { logger, showJsonRecord, selectedRecordIndex, filteredLogs } = props;
41 1 : const rowIndex = props.index;
42 :
43 : // Use filtered logs if provided, otherwise use the full buffer
44 1 : let record: LogRecord | null;
45 1 : if (filteredLogs) {
46 1 : record = filteredLogs[rowIndex] ?? null;
47 1 : } else {
48 1 : if (rowIndex >= logger.buffer.length) {
49 0 : return <div style={props.style} />;
50 : }
51 1 : record = logger.buffer.at(rowIndex);
52 1 : }
53 :
54 1 : if (!record) {
55 0 : return <div style={props.style} />;
56 : }
57 :
58 1 : const isSelected = rowIndex === selectedRecordIndex;
59 1 : return (
60 1 : <div
61 1 : className={`${styles.log_row} ${isSelected ? styles.log_row_selected : ''}`}
62 1 : style={props.style}
63 1 : onClick={() => showJsonRecord(rowIndex)}
64 : >
65 1 : <div className={styles.log_row_main}>
66 1 : <div className={styles.log_cell_timestamp}>
67 1 : {formatLogTimestamp(record.timestamp)}
68 1 : </div>
69 1 : <div className={styles.log_cell_level}>
70 1 : {getLogLevelName(record.level)}
71 1 : </div>
72 :
73 1 : <div className={styles.log_cell_message} title={record.message}>
74 1 : {record.message}
75 1 : </div>
76 1 : </div>
77 1 : </div>
78 : );
79 1 : };
80 :
81 : interface LogViewerProps {
82 : onClose: () => void;
83 : traceId?: number;
84 : }
85 :
86 1 : export const LogViewer: React.FC<LogViewerProps> = (props: LogViewerProps) => {
87 1 : const logger = useLogger();
88 1 : const logVersion = pollLogVersion(100);
89 1 : const fileDownloader = useFileDownloader();
90 :
91 : // Maintain filtered log records for trace-specific viewing.
92 : // We append to a mutable array and bump a version, copying the array per record is quadratic.
93 1 : const filteredLogsRef = React.useRef<LogRecord[]>([]);
94 1 : const [filteredLogsVersion, setFilteredLogsVersion] = React.useState<number>(0);
95 1 : const filteredLogs = filteredLogsRef.current;
96 :
97 : // Track which log record to show in JSON modal (record and index as tuple)
98 1 : const [jsonModalState, setJsonModalState] = React.useState<[LogRecord | null, number]>([null, -1]);
99 1 : const [jsonModalRecord, jsonModalRecordIndex] = jsonModalState;
100 :
101 : // Close the modal
102 1 : const closeRef = React.useRef<HTMLButtonElement>(null);
103 1 : const closeJsonModal = React.useCallback(() => {
104 1 : setJsonModalState([null, -1]);
105 1 : }, []);
106 :
107 : // Download logs as JSON
108 1 : const downloadLogs = React.useCallback(async () => {
109 1 : try {
110 : // Collect all logs (either filtered by trace or all logs)
111 1 : const logsToExport: LogRecord[] = [];
112 :
113 1 : if (props.traceId !== undefined) {
114 : // Export filtered logs for the trace
115 1 : logsToExport.push(...filteredLogs);
116 1 : } else {
117 : // Export all logs from the buffer
118 1 : const totalLogs = logger.buffer.length;
119 1 : for (let i = 0; i < totalLogs; i++) {
120 1 : const record = logger.buffer.at(i);
121 1 : if (record) {
122 1 : logsToExport.push(record);
123 : }
124 1 : }
125 1 : }
126 :
127 : // Convert logs to JSON with pretty printing
128 1 : const jsonContent = JSON.stringify(logsToExport, null, 2);
129 1 : const encoder = new TextEncoder();
130 1 : const uint8Array = encoder.encode(jsonContent);
131 :
132 : // Generate filename with timestamp
133 1 : const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
134 1 : const filename = props.traceId !== undefined
135 1 : ? `dashql-logs-trace-${props.traceId}-${timestamp}.json`
136 1 : : `dashql-logs-${timestamp}.json`;
137 :
138 : // Download the file
139 1 : await fileDownloader.downloadBufferAsFile(uint8Array, filename);
140 1 : } catch (error) {
141 1 : console.error('Failed to download logs:', error);
142 1 : }
143 1 : }, [logger, fileDownloader, props.traceId, filteredLogs, filteredLogsVersion]);
144 :
145 : // Subscribe to trace-specific logs if traceId is provided
146 1 : React.useEffect(() => {
147 1 : if (props.traceId === undefined) {
148 1 : return;
149 : }
150 :
151 : // Get initial logs for this trace
152 1 : const initialLogs = logger.buffer.collectTraceLogs(props.traceId);
153 1 : filteredLogsRef.current = initialLogs;
154 1 : setFilteredLogsVersion(v => v + 1);
155 :
156 : // Subscribe to new logs for this trace
157 1 : const observer = (record: LogRecord) => {
158 1 : initialLogs.push(record);
159 1 : setFilteredLogsVersion(v => v + 1);
160 1 : };
161 1 : logger.buffer.subscribeTrace(props.traceId, observer);
162 :
163 : // Cleanup subscription
164 1 : return () => {
165 1 : logger.buffer.unsubscribeTrace(props.traceId!, observer);
166 1 : };
167 1 : }, [props.traceId, logger]);
168 :
169 : // Determine log container dimensions
170 1 : const containerRef = React.useRef<HTMLDivElement>(null);
171 1 : const containerSize = observeSize(containerRef);
172 1 : const containerWidth = containerSize?.width ?? 200;
173 1 : const containerHeight = containerSize?.height ?? 100;
174 :
175 : // Track whether the list is scrolled to the bottom
176 1 : const [listApi, setListApi] = useListCallbackRef(null);
177 1 : const getScrollElement = React.useCallback(() => listApi?.element ?? null, [listApi]);
178 1 : const followTail = React.useRef<boolean>(true);
179 1 : React.useEffect(() => {
180 1 : const element = listApi?.element;
181 1 : if (!element) {
182 1 : return;
183 : }
184 1 : const onScroll = () => {
185 1 : followTail.current = element.scrollTop + element.clientHeight >= element.scrollHeight - ROW_HEIGHT / 2;
186 1 : };
187 1 : element.addEventListener('scroll', onScroll, { passive: true });
188 1 : return () => element.removeEventListener('scroll', onScroll);
189 1 : }, [listApi]);
190 :
191 : // Follow new log rows if the list was at the bottom and no record is inspected
192 1 : const isInspectingRecord = jsonModalRecord !== null;
193 1 : React.useEffect(() => {
194 1 : if (listApi && followTail.current && !isInspectingRecord) {
195 1 : const rowCount = props.traceId !== undefined ? filteredLogs.length : logger.buffer.length;
196 :
197 : // Scroll to last row, an empty list has none
198 1 : if (rowCount > 0) {
199 1 : listApi.scrollToRow({
200 1 : index: rowCount - 1,
201 1 : align: 'end',
202 1 : });
203 : }
204 1 : }
205 1 : }, [logVersion, containerHeight, filteredLogs, filteredLogsVersion, props.traceId, logger, listApi, isInspectingRecord]);
206 :
207 : // Helper to show JSON modal for a log record
208 1 : const showJsonRecord = React.useCallback((rowIndex: number) => {
209 1 : let record: LogRecord | null;
210 1 : if (props.traceId !== undefined) {
211 1 : record = filteredLogs[rowIndex] ?? null;
212 : } else {
213 1 : record = logger.buffer.at(rowIndex);
214 : }
215 1 : if (record) {
216 1 : setJsonModalState([record, rowIndex]);
217 : }
218 1 : }, [props.traceId, filteredLogs, filteredLogsVersion, logger]);
219 :
220 : // Navigate to previous log record
221 1 : const showPreviousRecord = React.useCallback(() => {
222 1 : if (jsonModalRecordIndex <= 0) return;
223 1 : showJsonRecord(jsonModalRecordIndex - 1);
224 1 : }, [jsonModalRecordIndex, showJsonRecord]);
225 :
226 : // Navigate to next log record
227 1 : const showNextRecord = React.useCallback(() => {
228 1 : const maxIndex = props.traceId !== undefined ? filteredLogs.length - 1 : logger.buffer.length - 1;
229 1 : if (jsonModalRecordIndex >= maxIndex) return;
230 1 : showJsonRecord(jsonModalRecordIndex + 1);
231 1 : }, [jsonModalRecordIndex, showJsonRecord, props.traceId, filteredLogs, filteredLogsVersion, logger]);
232 :
233 : // Keyboard navigation when modal is open
234 1 : useKeyEvents(
235 1 : jsonModalRecord
236 1 : ? [
237 1 : {
238 1 : key: 'ArrowUp',
239 1 : callback: (e: KeyboardEvent) => {
240 1 : e.preventDefault();
241 1 : e.stopPropagation();
242 1 : showPreviousRecord();
243 1 : },
244 1 : capture: true,
245 1 : },
246 1 : {
247 1 : key: 'ArrowDown',
248 1 : callback: (e: KeyboardEvent) => {
249 1 : e.preventDefault();
250 1 : e.stopPropagation();
251 1 : showNextRecord();
252 1 : },
253 1 : capture: true,
254 1 : },
255 1 : ]
256 1 : : []
257 1 : );
258 :
259 : // Scroll to the selected row when it changes
260 1 : React.useEffect(() => {
261 1 : if (jsonModalRecordIndex >= 0 && listApi) {
262 1 : listApi.scrollToRow({
263 1 : index: jsonModalRecordIndex,
264 1 : align: 'center',
265 1 : });
266 : }
267 1 : }, [jsonModalRecordIndex, listApi]);
268 :
269 : // Row props passed to the row component
270 1 : const rowProps = React.useMemo<LogRowProps>(() => ({
271 1 : logger,
272 1 : showJsonRecord,
273 1 : selectedRecordIndex: jsonModalRecordIndex,
274 1 : filteredLogs: props.traceId !== undefined ? filteredLogs : undefined,
275 1 : }), [logger, showJsonRecord, jsonModalRecordIndex, props.traceId, filteredLogs, filteredLogsVersion]);
276 :
277 : // Determine row count based on whether we're filtering by trace
278 1 : const rowCount = props.traceId !== undefined ? filteredLogs.length : logger.buffer.length;
279 :
280 1 : return (
281 1 : <>
282 1 : <div className={styles.overlay} data-electron-drag-region="false">
283 1 : <div className={styles.header_container}>
284 1 : <div className={styles.header_left_container}>
285 1 : <div className={styles.title}>
286 1 : {props.traceId !== undefined ? `Logs (Trace ${props.traceId})` : 'Logs'}
287 1 : </div>
288 1 : </div>
289 1 : <div className={styles.header_right_container}>
290 1 : <IconButton
291 1 : variant={ButtonVariant.Invisible}
292 1 : aria-label="Download Logs as JSON"
293 1 : onClick={downloadLogs}
294 : >
295 1 : <DownloadIcon />
296 1 : </IconButton>
297 1 : <IconButton
298 1 : ref={closeRef}
299 1 : variant={ButtonVariant.Invisible}
300 1 : aria-label="Close"
301 1 : onClick={props.onClose}
302 : >
303 1 : <XIcon />
304 1 : </IconButton>
305 1 : </div>
306 1 : </div>
307 1 : <div className={styles.log_header_row}>
308 1 : <div className={styles.log_header_main}>
309 1 : <div className={styles.log_header_timestamp}>Time</div>
310 1 : <div className={styles.log_header_level}>Level</div>
311 1 : <div className={styles.log_header_message}>Message</div>
312 1 : </div>
313 1 : </div>
314 1 : <div className={styles.log_grid_container} ref={containerRef}>
315 1 : <List
316 1 : listRef={setListApi}
317 1 : style={{ width: containerWidth, height: containerHeight }}
318 1 : rowCount={rowCount}
319 1 : rowHeight={ROW_HEIGHT}
320 1 : rowComponent={LogRow}
321 1 : rowProps={rowProps}
322 1 : />
323 1 : <VerticalScrollShadows
324 1 : getScrollElement={getScrollElement}
325 1 : refreshKey={rowCount}
326 1 : />
327 1 : </div>
328 1 : </div>
329 1 : <LogJsonModal
330 1 : record={jsonModalRecord}
331 1 : recordIndex={jsonModalRecordIndex}
332 1 : maxIndex={props.traceId !== undefined ? filteredLogs.length - 1 : logger.buffer.length - 1}
333 1 : anchorRef={containerRef}
334 1 : returnFocusRef={closeRef}
335 1 : align={AnchorAlignment.Start}
336 1 : side={AnchorSide.OutsideLeft}
337 1 : onClose={closeJsonModal}
338 1 : onPrevious={showPreviousRecord}
339 1 : onNext={showNextRecord}
340 1 : />
341 1 : </>
342 : );
343 1 : }
|