Line data Source code
1 : import * as React from 'react';
2 : import * as styles from '../ui/logs/log_viewer.module.css';
3 :
4 : import { List, useListRef } from 'react-window';
5 :
6 : import { LogRecord } from '../platform/logger/log_buffer.js';
7 : import { pollLogVersion, useLogger } from '../platform/logger/logger_provider.js';
8 : import { observeSize } from '../ui/foundations/size_observer.js';
9 : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
10 : import { useKeyEvents } from '../utils/key_events.js';
11 : import { LogJsonModal } from '../ui/logs/log_json_modal.js';
12 : import {
13 : LogRow,
14 : LogRowProps,
15 : ROW_HEIGHT,
16 : } from '../ui/logs/log_viewer.js';
17 :
18 : interface TraceLogViewerProps {
19 : traceId?: number;
20 : /// Fixed pixel height of the log viewport. Ignored when `maxRows` or `fill` is set.
21 : height?: number;
22 : /// Scrollless preview mode: the viewport auto-expands to fit the current row count and caps at
23 : /// this many rows. Beyond the cap the inner scrollbar is suppressed and only the last `maxRows`
24 : /// rows are shown (no nested scroller). Takes precedence over `height`.
25 : maxRows?: number;
26 : /// Fill mode: the viewport fills its parent's height (which must be constrained) and scrolls
27 : /// normally. Used by the Details Status tab, where the log should occupy the full card. Takes
28 : /// precedence over `height` (but not `maxRows`).
29 : fill?: boolean;
30 : }
31 :
32 1 : export const TraceLogViewer: React.FC<TraceLogViewerProps> = (props: TraceLogViewerProps) => {
33 1 : const logger = useLogger();
34 : // The trace subscription drives the re-renders, poll the log version only when showing all logs
35 1 : const logVersion = pollLogVersion(100, props.traceId === undefined);
36 :
37 : // Maintain filtered log records for trace-specific viewing.
38 : // We append to a mutable array and bump a version, copying the array per record is quadratic.
39 1 : const filteredLogsRef = React.useRef<LogRecord[]>([]);
40 1 : const [filteredLogsVersion, setFilteredLogsVersion] = React.useState<number>(0);
41 1 : const filteredLogs = filteredLogsRef.current;
42 :
43 : // Track which log record to show in JSON modal (record and index as tuple)
44 1 : const [jsonModalState, setJsonModalState] = React.useState<[LogRecord | null, number]>([null, -1]);
45 1 : const [jsonModalRecord, jsonModalRecordIndex] = jsonModalState;
46 :
47 : // Close the modal
48 1 : const closeJsonModal = React.useCallback(() => {
49 1 : setJsonModalState([null, -1]);
50 1 : }, []);
51 :
52 : // Subscribe to trace-specific logs if traceId is provided
53 1 : React.useEffect(() => {
54 1 : if (props.traceId === undefined) {
55 1 : return;
56 : }
57 :
58 : // Get initial logs for this trace
59 1 : const initialLogs = logger.buffer.collectTraceLogs(props.traceId);
60 1 : filteredLogsRef.current = initialLogs;
61 1 : setFilteredLogsVersion(v => v + 1);
62 :
63 : // Subscribe to new logs for this trace
64 1 : const observer = (record: LogRecord) => {
65 1 : initialLogs.push(record);
66 1 : setFilteredLogsVersion(v => v + 1);
67 1 : };
68 1 : logger.buffer.subscribeTrace(props.traceId, observer);
69 :
70 : // Cleanup subscription
71 1 : return () => {
72 1 : logger.buffer.unsubscribeTrace(props.traceId!, observer);
73 1 : };
74 1 : }, [props.traceId, logger]);
75 :
76 : // The number of rows currently displayed.
77 1 : const rowCount = props.traceId !== undefined ? filteredLogs.length : logger.buffer.length;
78 :
79 : // Determine log container dimensions. When `maxRows` is set (scrollless preview mode), the
80 : // viewport auto-expands to fit the current rows (so it grows as logs stream in) and caps at
81 : // `maxRows` rows; beyond the cap the inner scrollbar is suppressed and the list stays pinned to
82 : // the last `maxRows` rows (see the scroll-to-last effect below), so the feed's own scroller
83 : // stays the only one. Otherwise it falls back to the fixed `height` prop and scrolls normally.
84 1 : const containerRef = React.useRef<HTMLDivElement>(null);
85 1 : const containerSize = observeSize(containerRef);
86 1 : const containerWidth = containerSize?.width ?? 200;
87 : // In fill mode the container stretches to its parent (height: 100%) and the inner list is sized
88 : // to the measured height so react-window has an explicit pixel viewport to scroll within.
89 1 : const containerHeight = props.maxRows != null
90 1 : ? Math.min(rowCount, props.maxRows) * ROW_HEIGHT
91 1 : : props.fill
92 1 : ? (containerSize?.height ?? 0)
93 1 : : (props.height ?? 200);
94 :
95 : // Redraw whenever the log version changes or filtered logs change
96 1 : const seenLogRows = React.useRef<number>(0);
97 1 : const listRef = useListRef(null);
98 1 : React.useEffect(() => {
99 1 : if (!listRef.current) return;
100 1 : seenLogRows.current = rowCount;
101 1 : if (rowCount === 0) return;
102 :
103 : // Scroll to the last row. Unlike the modal log viewer, our container height is a fixed
104 : // prop rather than a measured size, so the effect never re-fires after layout settles.
105 : // We scroll synchronously and again on the next frame, once react-window has committed the
106 : // freshly-appended rows, so the view reliably sticks to the bottom as new logs stream in.
107 1 : const scrollToLast = () => listRef.current?.scrollToRow({ index: rowCount - 1, align: 'end' });
108 1 : scrollToLast();
109 1 : const raf = requestAnimationFrame(scrollToLast);
110 1 : return () => cancelAnimationFrame(raf);
111 1 : }, [logVersion, containerHeight, filteredLogs, filteredLogsVersion, props.traceId, logger]);
112 :
113 : // Helper to show JSON modal for a log record
114 1 : const showJsonRecord = React.useCallback((rowIndex: number) => {
115 1 : let record: LogRecord | null;
116 1 : if (props.traceId !== undefined) {
117 1 : record = filteredLogs[rowIndex] ?? null;
118 : } else {
119 1 : record = logger.buffer.at(rowIndex);
120 : }
121 1 : if (record) {
122 1 : setJsonModalState([record, rowIndex]);
123 : }
124 1 : }, [props.traceId, filteredLogs, filteredLogsVersion, logger]);
125 :
126 : // Navigate to previous log record
127 1 : const showPreviousRecord = React.useCallback(() => {
128 1 : if (jsonModalRecordIndex <= 0) return;
129 1 : showJsonRecord(jsonModalRecordIndex - 1);
130 1 : }, [jsonModalRecordIndex, showJsonRecord]);
131 :
132 : // Navigate to next log record
133 1 : const showNextRecord = React.useCallback(() => {
134 1 : const maxIndex = props.traceId !== undefined ? filteredLogs.length - 1 : logger.buffer.length - 1;
135 1 : if (jsonModalRecordIndex >= maxIndex) return;
136 1 : showJsonRecord(jsonModalRecordIndex + 1);
137 1 : }, [jsonModalRecordIndex, showJsonRecord, props.traceId, filteredLogs, filteredLogsVersion, logger]);
138 :
139 : // Keyboard navigation when modal is open
140 1 : useKeyEvents(
141 1 : jsonModalRecord
142 1 : ? [
143 1 : {
144 1 : key: 'ArrowUp',
145 1 : callback: (e: KeyboardEvent) => {
146 1 : e.preventDefault();
147 1 : e.stopPropagation();
148 1 : showPreviousRecord();
149 1 : },
150 1 : },
151 1 : {
152 1 : key: 'ArrowDown',
153 1 : callback: (e: KeyboardEvent) => {
154 1 : e.preventDefault();
155 1 : e.stopPropagation();
156 1 : showNextRecord();
157 1 : },
158 1 : },
159 1 : ]
160 1 : : []
161 1 : );
162 :
163 : // Scroll to the selected row when it changes
164 1 : React.useEffect(() => {
165 1 : if (jsonModalRecordIndex >= 0 && listRef.current) {
166 1 : listRef.current.scrollToRow({
167 1 : index: jsonModalRecordIndex,
168 1 : align: 'center',
169 1 : });
170 : }
171 1 : }, [jsonModalRecordIndex]);
172 :
173 : // Row props
174 1 : const rowProps = React.useMemo<LogRowProps>(() => ({
175 1 : logger,
176 1 : showJsonRecord,
177 1 : selectedRecordIndex: jsonModalRecordIndex,
178 1 : filteredLogs: props.traceId !== undefined ? filteredLogs : undefined,
179 1 : }), [logger, showJsonRecord, jsonModalRecordIndex, props.traceId, filteredLogs, filteredLogsVersion]);
180 :
181 1 : return (
182 1 : <>
183 1 : <div className={styles.log_grid_container} ref={containerRef} style={{ height: props.fill ? '100%' : containerHeight, position: 'relative' }}>
184 1 : <List
185 1 : listRef={listRef}
186 : // In scrollless preview mode (`maxRows`) the viewport is pinned to the last rows
187 : // and the feed's own scroller is the only one, so suppress the inner scrollbar.
188 : // Otherwise scroll normally — pass 'auto' explicitly rather than `undefined`, which
189 : // would override react-window's default `overflowY: 'auto'` and disable scrolling.
190 1 : style={{ width: containerWidth, height: containerHeight, overflowY: props.maxRows != null ? 'hidden' : 'auto' }}
191 1 : rowCount={rowCount}
192 1 : rowHeight={ROW_HEIGHT}
193 1 : rowComponent={LogRow}
194 1 : rowProps={rowProps}
195 1 : />
196 1 : </div>
197 1 : <LogJsonModal
198 1 : record={jsonModalRecord}
199 1 : recordIndex={jsonModalRecordIndex}
200 1 : maxIndex={props.traceId !== undefined ? filteredLogs.length - 1 : logger.buffer.length - 1}
201 1 : anchorRef={containerRef}
202 1 : align={AnchorAlignment.Start}
203 1 : side={AnchorSide.OutsideLeft}
204 1 : onClose={closeJsonModal}
205 1 : onPrevious={showPreviousRecord}
206 1 : onNext={showNextRecord}
207 1 : />
208 1 : </>
209 : );
210 1 : }
|