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