Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './query_history_viewer.module.css';
3 :
4 : import { List, useListCallbackRef } from 'react-window';
5 : import type { ListImperativeAPI, 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 { QueryExecutionState, QueryExecutionStatus } from './query_execution_state.js';
14 : import { observeSize } from '../ui/foundations/size_observer.js';
15 : import { useKeyEvents } from '../utils/key_events.js';
16 : import { VerticalScrollShadows } from '../ui/foundations/vertical_scroll_shadows.js';
17 :
18 : export const ROW_HEIGHT = 32;
19 :
20 1 : export enum QueryTarget {
21 1 : CONNECTION = 'Attached',
22 1 : MEMORY = 'Memory',
23 1 : }
24 :
25 1 : export function getQueryTarget(query: QueryExecutionState): QueryTarget {
26 1 : return query.queryMetadata.userProvided ? QueryTarget.CONNECTION : QueryTarget.MEMORY;
27 1 : }
28 :
29 : export interface QueryEntry {
30 : connectionId: string;
31 : sourceName: string;
32 : target: QueryTarget;
33 : queryId: number;
34 : query: QueryExecutionState;
35 : }
36 :
37 1 : function getStatusLabel(status: QueryExecutionStatus): string {
38 1 : switch (status) {
39 1 : case QueryExecutionStatus.REQUESTED: return 'requested';
40 0 : case QueryExecutionStatus.PREPARING: return 'preparing';
41 0 : case QueryExecutionStatus.SENDING: return 'sending';
42 0 : case QueryExecutionStatus.QUEUED: return 'queued';
43 0 : case QueryExecutionStatus.RUNNING: return 'running';
44 0 : case QueryExecutionStatus.RECEIVED_FIRST_BATCH: return 'receiving';
45 0 : case QueryExecutionStatus.RECEIVED_ALL_BATCHES: return 'received';
46 0 : case QueryExecutionStatus.PROCESSING_RESULTS: return 'processing';
47 0 : case QueryExecutionStatus.PROCESSED_RESULTS: return 'processed';
48 1 : case QueryExecutionStatus.SUCCEEDED: return 'succeeded';
49 0 : case QueryExecutionStatus.FAILED: return 'failed';
50 0 : case QueryExecutionStatus.CANCELLED: return 'cancelled';
51 0 : default: return 'unknown';
52 1 : }
53 1 : }
54 :
55 0 : function entryToObject(entry: QueryEntry): object {
56 0 : const q = entry.query;
57 0 : const m = q.metrics;
58 0 : return {
59 0 : queryId: q.queryId,
60 0 : traceId: q.traceId,
61 0 : status: QueryExecutionStatus[q.status],
62 0 : source: entry.sourceName,
63 0 : target: entry.target,
64 0 : queryText: q.queryText,
65 0 : metadata: q.queryMetadata,
66 0 : metrics: {
67 0 : queryRequestedAt: m.queryRequestedAt?.toISOString() ?? null,
68 0 : queryPreparingStartedAt: m.queryPreparingStartedAt?.toISOString() ?? null,
69 0 : querySendingStartedAt: m.querySendingStartedAt?.toISOString() ?? null,
70 0 : queryQueuedStartedAt: m.queryQueuedStartedAt?.toISOString() ?? null,
71 0 : queryRunningStartedAt: m.queryRunningStartedAt?.toISOString() ?? null,
72 0 : receivedFirstBatchAt: m.receivedFirstBatchAt?.toISOString() ?? null,
73 0 : receivedLastBatchAt: m.receivedLastBatchAt?.toISOString() ?? null,
74 0 : receivedAllBatchesAt: m.receivedAllBatchesAt?.toISOString() ?? null,
75 0 : processingResultsStartedAt: m.processingResultsStartedAt?.toISOString() ?? null,
76 0 : processedResultsAt: m.processedResultsAt?.toISOString() ?? null,
77 0 : querySucceededAt: m.querySucceededAt?.toISOString() ?? null,
78 0 : queryFailedAt: m.queryFailedAt?.toISOString() ?? null,
79 0 : queryCancelledAt: m.queryCancelledAt?.toISOString() ?? null,
80 0 : durationMs: m.queryDurationMs,
81 0 : progressUpdatesReceived: m.progressUpdatesReceived,
82 0 : },
83 0 : error: q.error?.message ?? null,
84 0 : };
85 0 : }
86 :
87 : interface QueryRowProps {
88 : entries: QueryEntry[];
89 : showDetail: (index: number) => void;
90 : selectedIndex: number;
91 : }
92 :
93 1 : export const QueryRow = (props: RowComponentProps<QueryRowProps>) => {
94 1 : const { entries, showDetail, selectedIndex } = props;
95 1 : const rowIndex = props.index;
96 1 : const entry = entries[rowIndex];
97 :
98 1 : if (!entry) {
99 0 : return <div style={props.style} />;
100 : }
101 :
102 1 : const isSelected = rowIndex === selectedIndex;
103 1 : const lastUpdatedAt = entry.query.metrics.lastUpdatedAt;
104 1 : const time = lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString('en-GB', { hour12: false }) : '—';
105 :
106 1 : return (
107 1 : <div
108 1 : className={`${styles.query_row} ${isSelected ? styles.query_row_selected : ''}`}
109 1 : style={props.style}
110 1 : onClick={() => showDetail(rowIndex)}
111 1 : data-testid="query-history-row"
112 : >
113 1 : <div className={styles.query_row_main}>
114 1 : <div className={styles.query_cell_time}>{time}</div>
115 1 : <div className={styles.query_cell_source}>{entry.sourceName}</div>
116 1 : <div className={styles.query_cell_target}>{entry.target}</div>
117 1 : <div className={styles.query_cell_status}>{getStatusLabel(entry.query.status)}</div>
118 1 : <div className={styles.query_cell_title} title={entry.query.queryMetadata.title ?? ''}>
119 1 : {entry.query.queryMetadata.title ?? '—'}
120 1 : </div>
121 1 : </div>
122 1 : </div>
123 : );
124 1 : };
125 :
126 : export interface QueryHistoryViewerDependencies {
127 : List: React.ComponentType<any>;
128 : useListCallbackRef(initialValue: ListImperativeAPI | null): [ListImperativeAPI | null, React.Dispatch<React.SetStateAction<ListImperativeAPI | null>>];
129 : }
130 :
131 1 : const DEFAULT_DEPENDENCIES: QueryHistoryViewerDependencies = {
132 1 : List,
133 1 : useListCallbackRef,
134 1 : };
135 :
136 1 : export function QueryHistoryViewer(props: {
137 : entries: QueryEntry[];
138 : onClose: () => void;
139 : dependencies?: QueryHistoryViewerDependencies;
140 1 : }) {
141 1 : const { entries } = props;
142 1 : const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
143 1 : const ListComponent = dependencies.List;
144 : // Container dimensions
145 1 : const containerRef = React.useRef<HTMLDivElement>(null);
146 1 : const containerSize = observeSize(containerRef);
147 1 : const containerWidth = containerSize?.width ?? 200;
148 1 : const containerHeight = containerSize?.height ?? 100;
149 :
150 : // Detail modal state: [entry | null, index]
151 1 : const [modalState, setModalState] = React.useState<[QueryEntry | null, number]>([null, -1]);
152 1 : const [modalEntry, modalIndex] = modalState;
153 :
154 1 : const closeRef = React.useRef<HTMLButtonElement>(null);
155 1 : const closeModal = React.useCallback(() => setModalState([null, -1]), []);
156 :
157 1 : const showDetail = React.useCallback((index: number) => {
158 0 : const entry = entries[index] ?? null;
159 0 : if (entry) setModalState([entry, index]);
160 1 : }, [entries]);
161 :
162 1 : const showPrevious = React.useCallback(() => {
163 0 : if (modalIndex <= 0) return;
164 0 : showDetail(modalIndex - 1);
165 1 : }, [modalIndex, showDetail]);
166 :
167 1 : const showNext = React.useCallback(() => {
168 0 : if (modalIndex >= entries.length - 1) return;
169 0 : showDetail(modalIndex + 1);
170 1 : }, [modalIndex, entries.length, showDetail]);
171 :
172 1 : useKeyEvents(
173 1 : modalEntry
174 0 : ? [
175 0 : {
176 0 : key: 'ArrowUp',
177 0 : callback: (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); showPrevious(); },
178 0 : capture: true,
179 0 : },
180 0 : {
181 0 : key: 'ArrowDown',
182 0 : callback: (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); showNext(); },
183 0 : capture: true,
184 0 : },
185 0 : ]
186 1 : : []
187 1 : );
188 :
189 : // Auto-scroll to bottom when entries change; scroll to selected row when modal opens
190 1 : const [listApi, setListApi] = dependencies.useListCallbackRef(null);
191 1 : const getScrollElement = React.useCallback(() => listApi?.element ?? null, [listApi]);
192 1 : React.useEffect(() => {
193 1 : if (modalIndex >= 0 && listApi) {
194 0 : listApi.scrollToRow({ index: modalIndex, align: 'center' });
195 1 : } else if (listApi && entries.length > 0) {
196 1 : listApi.scrollToRow({ index: entries.length - 1, align: 'end' });
197 : }
198 1 : }, [entries, modalIndex, listApi]);
199 :
200 1 : const rowProps = React.useMemo<QueryRowProps>(() => ({
201 1 : entries,
202 1 : showDetail,
203 1 : selectedIndex: modalIndex,
204 1 : }), [entries, showDetail, modalIndex]);
205 :
206 1 : const modalObject = React.useMemo<object | null>(
207 1 : () => modalEntry ? entryToObject(modalEntry) : null,
208 1 : [modalEntry]
209 1 : );
210 :
211 1 : return (
212 1 : <>
213 1 : <div className={styles.overlay}>
214 1 : <div className={styles.header_container}>
215 1 : <div className={styles.header_left_container}>
216 1 : <div className={styles.title}>Queries</div>
217 1 : </div>
218 1 : <div className={styles.header_right_container}>
219 1 : <IconButton
220 1 : ref={closeRef}
221 1 : variant={ButtonVariant.Invisible}
222 1 : aria-label="close-overlay"
223 1 : onClick={props.onClose}
224 : >
225 1 : <XIcon />
226 1 : </IconButton>
227 1 : </div>
228 1 : </div>
229 1 : <div className={styles.query_header_row}>
230 1 : <div className={styles.query_header_main}>
231 1 : <div className={styles.query_header_time}>Time</div>
232 1 : <div className={styles.query_header_source}>Source</div>
233 1 : <div className={styles.query_header_target}>Target</div>
234 1 : <div className={styles.query_header_status}>Status</div>
235 1 : <div className={styles.query_header_title}>Title</div>
236 1 : </div>
237 1 : </div>
238 1 : <div className={styles.query_grid_container} ref={containerRef}>
239 1 : {entries.length === 0 ? (
240 0 : <div className={styles.empty_state}>Nothing to see here</div>
241 : ) : (
242 1 : <ListComponent
243 1 : listRef={setListApi}
244 1 : style={{ width: containerWidth, height: containerHeight }}
245 1 : rowCount={entries.length}
246 1 : rowHeight={ROW_HEIGHT}
247 1 : rowComponent={QueryRow}
248 1 : rowProps={rowProps}
249 1 : />
250 : )}
251 1 : <VerticalScrollShadows
252 1 : getScrollElement={getScrollElement}
253 1 : refreshKey={entries.length}
254 1 : />
255 1 : </div>
256 1 : </div>
257 1 : <AnchoredOverlay
258 1 : renderAnchor={null}
259 1 : anchorRef={containerRef}
260 1 : returnFocusRef={closeRef}
261 1 : open={modalEntry !== null}
262 1 : onClose={closeModal}
263 1 : width={OverlaySize.L}
264 1 : align={AnchorAlignment.Start}
265 1 : side={AnchorSide.OutsideLeft}
266 : >
267 1 : <div className={styles.detail_modal}>
268 1 : <div className={styles.detail_modal_main}>
269 1 : <div className={styles.detail_modal_content}>
270 1 : {modalObject && <JsonView value={modalObject} />}
271 1 : </div>
272 1 : </div>
273 1 : <div className={styles.detail_modal_sidebar}>
274 1 : <div className={styles.detail_modal_sidebar_top}>
275 1 : <IconButton
276 1 : variant={ButtonVariant.Invisible}
277 1 : aria-label="Close"
278 1 : onClick={closeModal}
279 : >
280 1 : <XIcon />
281 1 : </IconButton>
282 1 : </div>
283 1 : <div className={styles.detail_modal_sidebar_bottom}>
284 1 : <IconButton
285 1 : variant={ButtonVariant.Invisible}
286 1 : aria-label="Previous entry"
287 1 : onClick={showPrevious}
288 1 : disabled={modalIndex <= 0}
289 : >
290 1 : <ChevronUpIcon />
291 1 : </IconButton>
292 1 : <IconButton
293 1 : variant={ButtonVariant.Invisible}
294 1 : aria-label="Next entry"
295 1 : onClick={showNext}
296 1 : disabled={modalIndex >= entries.length - 1}
297 : >
298 1 : <ChevronDownIcon />
299 1 : </IconButton>
300 1 : </div>
301 1 : </div>
302 1 : </div>
303 1 : </AnchoredOverlay>
304 1 : </>
305 : );
306 1 : }
|