LCOV - code coverage report
Current view: top level - dashql-app/src/session/persistence - query_cache_view.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.1 % 155 152
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './query_cache_view.module.css';
       3              : 
       4              : import { List, useListCallbackRef } from 'react-window';
       5              : import type { RowComponentProps } from 'react-window';
       6              : import { XIcon, TrashIcon, SyncIcon } from '../../ui/foundations/symbol_icon.js';
       7              : 
       8              : import { ButtonVariant, ButtonSize, IconButton } from '../../ui/foundations/button.js';
       9              : import type { QueryResultCacheEntry } from '../../query/query_result_cache.js';
      10              : import { markDeletedQueryResults } from './query_result_cache_clear.js';
      11              : import { useDynamicConnectionDispatch } from '../../connections/connection_registry.js';
      12              : import { formatBytes } from '../../utils/format.js';
      13              : import { formatRelativeTime } from '../../utils/time_format.js';
      14              : import { observeSize } from '../../ui/foundations/size_observer.js';
      15              : import { useLogger } from '../../platform/logger/logger_provider.js';
      16              : import { useQueryResultCache } from '../../storage/query_result_cache_provider.js';
      17              : import { VerticalScrollShadows } from '../../ui/foundations/vertical_scroll_shadows.js';
      18              : 
      19            1 : const LOG_CTX = 'query_cache_view';
      20              : 
      21              : export const ROW_HEIGHT = 32;
      22              : 
      23              : /// The rows show the cached results of the app by their cache keys
      24              : export interface QueryCacheRowProps {
      25              :     entries: QueryResultCacheEntry[];
      26              :     deleteEntry: (key: string) => void;
      27              : }
      28              : 
      29            1 : export const QueryCacheRow = (props: RowComponentProps<QueryCacheRowProps>) => {
      30            1 :     const { entries, deleteEntry } = props;
      31            1 :     const entry = entries[props.index];
      32              : 
      33            1 :     if (!entry) {
      34            0 :         return <div style={props.style} />;
      35              :     }
      36              : 
      37            1 :     const hash = entry.key;
      38            1 :     return (
      39            1 :         <div className={styles.stat_row} style={props.style}>
      40            1 :             <div className={styles.stat_row_main}>
      41            1 :                 <div className={styles.stat_cell_hash} title={hash}>
      42            1 :                     {hash}
      43            1 :                 </div>
      44            1 :                 <div className={styles.stat_cell_size}>
      45            1 :                     {formatBytes(entry.size)}
      46            1 :                 </div>
      47            1 :                 <div className={styles.stat_cell_cached} title={new Date(entry.cachedAtMs).toISOString()}>
      48            1 :                     {formatRelativeTime(new Date(entry.cachedAtMs))}
      49            1 :                 </div>
      50            1 :                 <div className={styles.stat_cell_accessed} title={new Date(entry.lastAccessMs).toISOString()}>
      51            1 :                     {formatRelativeTime(new Date(entry.lastAccessMs))}
      52            1 :                 </div>
      53            1 :                 <div className={styles.stat_cell_actions}>
      54            1 :                     <IconButton
      55            1 :                         variant={ButtonVariant.Invisible}
      56            1 :                         size={ButtonSize.Small}
      57            1 :                         aria-label="Delete cache entry"
      58            1 :                         onClick={() => deleteEntry(entry.key)}
      59              :                     >
      60            1 :                         <TrashIcon />
      61            1 :                     </IconButton>
      62            1 :                 </div>
      63            1 :             </div>
      64            1 :         </div>
      65              :     );
      66            1 : };
      67              : 
      68              : /// Sort by cached (write) time descending — most recent entries first.
      69            1 : function sortEntries(entries: QueryResultCacheEntry[]): QueryResultCacheEntry[] {
      70            1 :     return [...entries].sort((a, b) => b.cachedAtMs - a.cachedAtMs);
      71            1 : }
      72              : 
      73            1 : export function QueryCacheView(props: { onClose: () => void; }) {
      74            1 :     const logger = useLogger();
      75            1 :     const cache = useQueryResultCache();
      76              :     // The finished queries learn about deleted results, the registry is read after the delete
      77            1 :     const [databaseRegistry, databaseDispatch] = useDynamicConnectionDispatch();
      78            1 :     const databaseRegistryRef = React.useRef(databaseRegistry);
      79            1 :     databaseRegistryRef.current = databaseRegistry;
      80              : 
      81              : 
      82            1 :     const [entries, setEntries] = React.useState<QueryResultCacheEntry[]>([]);
      83              : 
      84              :     // Load (and reload) the cache listing of the app. Best-effort: a failure leaves the list empty and is logged,
      85              :     // mirroring how the cache itself treats every access as best-effort.
      86            1 :     const refresh = React.useCallback(async () => {
      87            1 :         if (cache == null) {
      88            1 :             setEntries([]);
      89            1 :             return;
      90            1 :         }
      91            1 :         try {
      92            1 :             setEntries(sortEntries(await cache.list()));
      93            1 :         } catch (e: any) {
      94            1 :             logger.warn('failed to list query result cache', { error: String(e?.message ?? e) }, LOG_CTX);
      95            1 :             setEntries([]);
      96            1 :         }
      97            1 :     }, [cache, logger]);
      98              : 
      99            1 :     React.useEffect(() => {
     100            1 :         void refresh();
     101            1 :     }, [refresh]);
     102              : 
     103            1 :     const deleteEntry = React.useCallback(async (key: string) => {
     104            1 :         if (cache == null) {
     105            0 :             return;
     106              :         }
     107            1 :         try {
     108              :             // A file that is read right now stays, the key is still cached
     109            1 :             if (await cache.delete(key)) markDeletedQueryResults(databaseRegistryRef.current, databaseDispatch, [key]);
     110            1 :         } catch (e: any) {
     111            1 :             logger.warn('failed to delete query result cache entry', { error: String(e?.message ?? e) }, LOG_CTX);
     112            1 :         }
     113            1 :         await refresh();
     114            1 :     }, [cache, logger, refresh, databaseDispatch]);
     115              : 
     116            1 :     const clearAll = React.useCallback(async () => {
     117            1 :         if (cache == null) {
     118            0 :             return;
     119              :         }
     120            1 :         try {
     121            1 :             const keys = await cache.clear();
     122            1 :             const queries = markDeletedQueryResults(databaseRegistryRef.current, databaseDispatch, keys);
     123            1 :             logger.info('cleared the query result cache', {
     124            1 :                 entries: String(keys.length),
     125            1 :                 queries: String(queries),
     126            1 :             }, LOG_CTX);
     127            1 :         } catch (e: any) {
     128            1 :             logger.warn('failed to clear the query result cache', { error: String(e?.message ?? e) }, LOG_CTX);
     129            1 :         }
     130            1 :         await refresh();
     131            1 :     }, [cache, logger, refresh, databaseDispatch]);
     132              : 
     133              :     // Aggregate totals for the header summary.
     134            1 :     const totalBytes = React.useMemo(() => entries.reduce((sum, e) => sum + e.size, 0), [entries]);
     135              : 
     136              :     // Container size for the virtual list
     137            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
     138            1 :     const containerSize = observeSize(containerRef);
     139            1 :     const containerWidth = containerSize?.width ?? 200;
     140            1 :     const containerHeight = containerSize?.height ?? 100;
     141              : 
     142            1 :     const [listApi, setListApi] = useListCallbackRef(null);
     143            1 :     const getScrollElement = React.useCallback(() => listApi?.element ?? null, [listApi]);
     144            1 :     const rowProps = React.useMemo<QueryCacheRowProps>(() => ({
     145            1 :         entries,
     146            1 :         deleteEntry,
     147            1 :     }), [entries, deleteEntry]);
     148              : 
     149            1 :     return (
     150            1 :         <div className={styles.overlay}>
     151            1 :             <div className={styles.header_container}>
     152            1 :                 <div className={styles.header_left_container}>
     153            1 :                     <div className={styles.title}>Cache</div>
     154            1 :                     {entries.length > 0 && (
     155            1 :                         <div className={styles.summary}>
     156            1 :                             {entries.length} {entries.length === 1 ? 'entry' : 'entries'} · {formatBytes(totalBytes)}
     157            1 :                         </div>
     158              :                     )}
     159            1 :                 </div>
     160            1 :                 <div className={styles.header_right_container}>
     161            1 :                     <IconButton
     162            1 :                         variant={ButtonVariant.Invisible}
     163            1 :                         aria-label="Refresh cache listing"
     164            1 :                         onClick={() => void refresh()}
     165              :                     >
     166            1 :                         <SyncIcon />
     167            1 :                     </IconButton>
     168            1 :                     <IconButton
     169            1 :                         variant={ButtonVariant.Invisible}
     170            1 :                         aria-label="Clear the query cache"
     171            1 :                         disabled={entries.length === 0}
     172            1 :                         onClick={() => void clearAll()}
     173              :                     >
     174            1 :                         <TrashIcon />
     175            1 :                     </IconButton>
     176            1 :                     <IconButton
     177            1 :                         variant={ButtonVariant.Invisible}
     178            1 :                         aria-label="close-overlay"
     179            1 :                         onClick={props.onClose}
     180              :                     >
     181            1 :                         <XIcon />
     182            1 :                     </IconButton>
     183            1 :                 </div>
     184            1 :             </div>
     185            1 :             <div className={styles.stat_header_row}>
     186            1 :                 <div className={styles.stat_header_main}>
     187            1 :                     <div className={styles.stat_cell_hash}>Key</div>
     188            1 :                     <div className={styles.stat_cell_size}>Size</div>
     189            1 :                     <div className={styles.stat_cell_cached}>Cached</div>
     190            1 :                     <div className={styles.stat_cell_accessed}>Accessed</div>
     191            1 :                     <div className={styles.stat_cell_actions} />
     192            1 :                 </div>
     193            1 :             </div>
     194            1 :             <div className={styles.stat_grid_container} ref={containerRef}>
     195            1 :                 {entries.length === 0 ? (
     196            1 :                     <div className={styles.empty_state}>
     197              :                         Nothing to see here
     198            1 :                     </div>
     199              :                 ) : (
     200            1 :                     <List
     201            1 :                         listRef={setListApi}
     202            1 :                         style={{ width: containerWidth, height: containerHeight }}
     203            1 :                         rowCount={entries.length}
     204            1 :                         rowHeight={ROW_HEIGHT}
     205            1 :                         rowComponent={QueryCacheRow}
     206            1 :                         rowProps={rowProps}
     207            1 :                     />
     208              :                 )}
     209            1 :                 <VerticalScrollShadows
     210            1 :                     getScrollElement={getScrollElement}
     211            1 :                     refreshKey={entries.length}
     212            1 :                 />
     213            1 :             </div>
     214            1 :         </div>
     215              :     );
     216            1 : }
        

Generated by: LCOV version 2.0-1