LCOV - code coverage report
Current view: top level - dashql-app/src/query - query_history_viewer.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 72.7 % 231 168
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1