Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './storage_writer_view.module.css';
3 :
4 : import { List, useListCallbackRef } from 'react-window';
5 : import type { RowComponentProps } from 'react-window';
6 : import { XIcon, ChevronUpIcon, ChevronDownIcon } from '../../ui/foundations/symbol_icon.js';
7 :
8 : import { ButtonVariant, IconButton } from '../../ui/foundations/button.js';
9 : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
10 : import { OverlaySize } from '../../ui/foundations/overlay.js';
11 : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
12 : import { JsonView } from '../../ui/json/json_view.js';
13 : import { useStorageWriter } from '../../storage/storage_provider.js';
14 : import { StorageWriteKey, StorageWriterStatistics, StorageWriteStatisticsMap, storageWriteKeyBelongsToSession, storageWriteKeyWithinSession } from '../../storage/storage_writer.js';
15 : import { formatBytes, formatMilliseconds } from '../../utils/format.js';
16 : import { observeSize } from '../../ui/foundations/size_observer.js';
17 : import { useKeyEvents } from '../../utils/key_events.js';
18 : import { VerticalScrollShadows } from '../../ui/foundations/vertical_scroll_shadows.js';
19 :
20 : export const ROW_HEIGHT = 32;
21 :
22 : export interface StorageWriterEntry {
23 : key: StorageWriteKey;
24 : /// The key with the active session's id prefix stripped, shown in the Key column (the full key
25 : /// stays in the row tooltip and the detail modal).
26 : label: string;
27 : stats: StorageWriterStatistics;
28 : }
29 :
30 : export interface StorageWriterRowProps {
31 : entries: StorageWriterEntry[];
32 : showDetail: (rowIndex: number) => void;
33 : selectedIndex: number;
34 : }
35 :
36 1 : export const StorageWriterRow = (props: RowComponentProps<StorageWriterRowProps>) => {
37 1 : const { entries, showDetail, selectedIndex } = props;
38 1 : const rowIndex = props.index;
39 1 : const entry = entries[rowIndex];
40 :
41 1 : if (!entry) {
42 0 : return <div style={props.style} />;
43 : }
44 :
45 1 : const isSelected = rowIndex === selectedIndex;
46 1 : return (
47 1 : <div
48 1 : className={`${styles.stat_row} ${isSelected ? styles.stat_row_selected : ''}`}
49 1 : style={props.style}
50 1 : onClick={() => showDetail(rowIndex)}
51 : >
52 1 : <div className={styles.stat_row_main}>
53 1 : <div className={styles.stat_cell_key} title={entry.key}>
54 1 : {entry.label}
55 1 : </div>
56 1 : <div className={styles.stat_cell_tasks}>
57 1 : {entry.stats.totalScheduledWrites}
58 1 : </div>
59 1 : <div className={styles.stat_cell_writes}>
60 1 : {entry.stats.totalWrites}
61 1 : </div>
62 1 : <div className={styles.stat_cell_time}>
63 1 : {formatMilliseconds(entry.stats.totalWriteTime)}
64 1 : </div>
65 1 : <div className={styles.stat_cell_bytes}>
66 1 : {formatBytes(entry.stats.totalWrittenBytes)}
67 1 : </div>
68 1 : </div>
69 1 : </div>
70 : );
71 1 : };
72 :
73 1 : function buildEntries(statsMap: StorageWriteStatisticsMap, sessionId: string): StorageWriterEntry[] {
74 1 : const entries: StorageWriterEntry[] = [...statsMap.entries()]
75 1 : .filter(([key]) => storageWriteKeyBelongsToSession(key, sessionId))
76 1 : .map(([key, stats]) => ({ key, label: storageWriteKeyWithinSession(key, sessionId), stats }));
77 : // Sort by last write ascending, the list scrolls to its end where the most recent write is.
78 : // Entries with no write come first.
79 1 : entries.sort((a, b) => {
80 1 : const at = a.stats.lastWrite?.getTime() ?? -1;
81 1 : const bt = b.stats.lastWrite?.getTime() ?? -1;
82 1 : return at - bt;
83 1 : });
84 1 : return entries;
85 1 : }
86 :
87 1 : function entryToObject(entry: StorageWriterEntry): object {
88 1 : return {
89 1 : key: entry.key,
90 1 : totalScheduledWrites: entry.stats.totalScheduledWrites,
91 1 : totalWrites: entry.stats.totalWrites,
92 1 : totalWrittenBytes: entry.stats.totalWrittenBytes,
93 1 : totalWriteTime: entry.stats.totalWriteTime,
94 1 : lastWrite: entry.stats.lastWrite?.toISOString() ?? null,
95 1 : };
96 1 : }
97 :
98 1 : export function StorageWriterView(props: { sessionId: string | null; onClose: () => void; }) {
99 1 : const { sessionId } = props;
100 :
101 : // Subscribe for storage write statistics
102 1 : const storageWriter = useStorageWriter();
103 1 : const [statsMap, setStatsMap] = React.useState<StorageWriteStatisticsMap | null>(storageWriter.getStatistics());
104 1 : React.useEffect(() => {
105 1 : const listener = setStatsMap;
106 1 : storageWriter.subscribeStatisticsListener(listener);
107 1 : return () => storageWriter.unsubscribeStatisticsListener(listener);
108 1 : }, []);
109 :
110 : // Build sorted entries from the stats map, scoped to the active session.
111 1 : const entries = React.useMemo<StorageWriterEntry[]>(() => {
112 1 : if (!statsMap || sessionId == null) return [];
113 1 : return buildEntries(statsMap, sessionId);
114 1 : }, [statsMap, sessionId]);
115 :
116 : // Container size for the virtual list
117 1 : const containerRef = React.useRef<HTMLDivElement>(null);
118 1 : const containerSize = observeSize(containerRef);
119 1 : const containerWidth = containerSize?.width ?? 200;
120 1 : const containerHeight = containerSize?.height ?? 100;
121 :
122 : // Detail modal state: [entry | null, index]
123 1 : const [modalState, setModalState] = React.useState<[StorageWriterEntry | null, number]>([null, -1]);
124 1 : const [modalEntry, modalIndex] = modalState;
125 :
126 1 : const closeRef = React.useRef<HTMLButtonElement>(null);
127 1 : const closeModal = React.useCallback(() => setModalState([null, -1]), []);
128 :
129 1 : const showDetail = React.useCallback((rowIndex: number) => {
130 1 : const entry = entries[rowIndex] ?? null;
131 1 : if (entry) {
132 1 : setModalState([entry, rowIndex]);
133 : }
134 1 : }, [entries]);
135 :
136 1 : const showPrevious = React.useCallback(() => {
137 1 : if (modalIndex <= 0) return;
138 1 : showDetail(modalIndex - 1);
139 1 : }, [modalIndex, showDetail]);
140 :
141 1 : const showNext = React.useCallback(() => {
142 1 : if (modalIndex >= entries.length - 1) return;
143 1 : showDetail(modalIndex + 1);
144 1 : }, [modalIndex, entries.length, showDetail]);
145 :
146 : // Keyboard navigation when modal is open
147 1 : useKeyEvents(
148 1 : modalEntry
149 1 : ? [
150 1 : {
151 1 : key: 'ArrowUp',
152 1 : callback: (e: KeyboardEvent) => {
153 1 : e.preventDefault();
154 1 : e.stopPropagation();
155 1 : showPrevious();
156 1 : },
157 1 : capture: true,
158 1 : },
159 1 : {
160 1 : key: 'ArrowDown',
161 1 : callback: (e: KeyboardEvent) => {
162 1 : e.preventDefault();
163 1 : e.stopPropagation();
164 1 : showNext();
165 1 : },
166 1 : capture: true,
167 1 : },
168 1 : ]
169 1 : : []
170 1 : );
171 :
172 : // Auto-scroll to bottom when entries change; scroll to selected row when modal opens
173 1 : const [listApi, setListApi] = useListCallbackRef(null);
174 1 : const getScrollElement = React.useCallback(() => listApi?.element ?? null, [listApi]);
175 1 : React.useEffect(() => {
176 1 : if (modalIndex >= 0 && listApi) {
177 1 : listApi.scrollToRow({ index: modalIndex, align: 'center' });
178 1 : } else if (listApi && entries.length > 0) {
179 1 : listApi.scrollToRow({ index: entries.length - 1, align: 'end' });
180 : }
181 1 : }, [entries, modalIndex, listApi]);
182 :
183 1 : const rowProps = React.useMemo<StorageWriterRowProps>(() => ({
184 1 : entries,
185 1 : showDetail,
186 1 : selectedIndex: modalIndex,
187 1 : }), [entries, showDetail, modalIndex]);
188 :
189 1 : const modalObject = React.useMemo<object | null>(
190 1 : () => modalEntry ? entryToObject(modalEntry) : null,
191 1 : [modalEntry]
192 1 : );
193 :
194 1 : return (
195 1 : <>
196 1 : <div className={styles.overlay}>
197 1 : <div className={styles.header_container}>
198 1 : <div className={styles.header_left_container}>
199 1 : <div className={styles.title}>Storage Writer</div>
200 1 : </div>
201 1 : <div className={styles.header_right_container}>
202 1 : <IconButton
203 1 : ref={closeRef}
204 1 : variant={ButtonVariant.Invisible}
205 1 : aria-label="close-overlay"
206 1 : onClick={props.onClose}
207 : >
208 1 : <XIcon />
209 1 : </IconButton>
210 1 : </div>
211 1 : </div>
212 1 : <div className={styles.stat_header_row}>
213 1 : <div className={styles.stat_header_main}>
214 1 : <div className={styles.stat_cell_key}>Key</div>
215 1 : <div className={styles.stat_cell_tasks}>Tasks</div>
216 1 : <div className={styles.stat_cell_writes}>Writes</div>
217 1 : <div className={styles.stat_cell_time}>Time</div>
218 1 : <div className={styles.stat_cell_bytes}>Bytes</div>
219 1 : </div>
220 1 : </div>
221 1 : <div className={styles.stat_grid_container} ref={containerRef}>
222 1 : {entries.length === 0 ? (
223 1 : <div className={styles.empty_state}>
224 1 : {sessionId == null ? 'Select a session to see its storage writes' : 'Nothing to see here'}
225 1 : </div>
226 : ) : (
227 1 : <List
228 1 : listRef={setListApi}
229 1 : style={{ width: containerWidth, height: containerHeight }}
230 1 : rowCount={entries.length}
231 1 : rowHeight={ROW_HEIGHT}
232 1 : rowComponent={StorageWriterRow}
233 1 : rowProps={rowProps}
234 1 : />
235 : )}
236 1 : <VerticalScrollShadows
237 1 : getScrollElement={getScrollElement}
238 1 : refreshKey={entries.length}
239 1 : />
240 1 : </div>
241 1 : </div>
242 1 : <AnchoredOverlay
243 1 : renderAnchor={null}
244 1 : anchorRef={containerRef}
245 1 : returnFocusRef={closeRef}
246 1 : open={modalEntry !== null}
247 1 : onClose={closeModal}
248 1 : width={OverlaySize.L}
249 1 : align={AnchorAlignment.Start}
250 1 : side={AnchorSide.OutsideLeft}
251 : >
252 1 : <div className={styles.detail_modal}>
253 1 : <div className={styles.detail_modal_main}>
254 1 : <div className={styles.detail_modal_content}>
255 1 : {modalObject && <JsonView value={modalObject} />}
256 1 : </div>
257 1 : </div>
258 1 : <div className={styles.detail_modal_sidebar}>
259 1 : <div className={styles.detail_modal_sidebar_top}>
260 1 : <IconButton
261 1 : variant={ButtonVariant.Invisible}
262 1 : aria-label="Close"
263 1 : onClick={closeModal}
264 : >
265 1 : <XIcon />
266 1 : </IconButton>
267 1 : </div>
268 1 : <div className={styles.detail_modal_sidebar_bottom}>
269 1 : <IconButton
270 1 : variant={ButtonVariant.Invisible}
271 1 : aria-label="Previous entry"
272 1 : onClick={showPrevious}
273 1 : disabled={modalIndex <= 0}
274 : >
275 1 : <ChevronUpIcon />
276 1 : </IconButton>
277 1 : <IconButton
278 1 : variant={ButtonVariant.Invisible}
279 1 : aria-label="Next entry"
280 1 : onClick={showNext}
281 1 : disabled={modalIndex >= entries.length - 1}
282 : >
283 1 : <ChevronDownIcon />
284 1 : </IconButton>
285 1 : </div>
286 1 : </div>
287 1 : </div>
288 1 : </AnchoredOverlay>
289 1 : </>
290 : );
291 1 : }
|