Line data Source code
1 : import * as arrow from 'apache-arrow';
2 : import * as React from 'react';
3 : import * as styles from './query_result_header.module.css';
4 :
5 : import { QueryExecutionStatus, type QueryExecutionState } from '../../query/query_execution_state.js';
6 : import { TabHeader } from '../../session/tab_header.js';
7 : import { QueryResultView } from './query_result_view.js';
8 : import { TableColumnHeader } from './data_table_cell.js';
9 : import { QueryResultToolbar, useQueryResultRowCounts } from './query_result_toolbar.js';
10 : import { QueryResultDownloadButton } from './query_result_download_button.js';
11 : import { QueryResultActionMenu } from './query_result_action_menu.js';
12 : import { fittingRowCount } from './data_table.js';
13 : import { QueryStatusPlaceholder } from '../card_placeholder.js';
14 : import { formatBytes } from '../../utils/format.js';
15 :
16 : export interface QueryResultHeaderDependencies {
17 : useQueryResultRowCounts: typeof useQueryResultRowCounts;
18 : QueryResultToolbar: typeof QueryResultToolbar;
19 : QueryResultDownloadButton: typeof QueryResultDownloadButton;
20 : QueryResultActionMenu: typeof QueryResultActionMenu;
21 : }
22 :
23 1 : const DEFAULT_HEADER_DEPENDENCIES: QueryResultHeaderDependencies = {
24 1 : useQueryResultRowCounts,
25 1 : QueryResultToolbar,
26 1 : QueryResultDownloadButton,
27 1 : QueryResultActionMenu,
28 1 : };
29 :
30 : /// The row count of a result table: "3 rows", "2 of 3 rows" while searching, "6 of 1000 rows" for a
31 : /// preview that shows at most `maxRows` rows. Null while the total is unknown.
32 1 : export function formatResultRowCount(counts: ReturnType<typeof useQueryResultRowCounts>, maxRows: number = Infinity): string | null {
33 1 : const { totalRows, currentRows, matchingRows } = counts;
34 1 : if (totalRows == null) return null;
35 1 : if (matchingRows != null) {
36 1 : return matchingRows > maxRows
37 1 : ? `${maxRows} of ${matchingRows} matching rows`
38 1 : : `${matchingRows} of ${currentRows ?? totalRows} rows`;
39 : }
40 1 : return totalRows > maxRows
41 1 : ? `${maxRows} of ${totalRows} rows`
42 1 : : `${totalRows} ${totalRows === 1 ? 'row' : 'rows'}`;
43 1 : }
44 :
45 : /// The bytes of a result table, weakly keyed by the table so a size never outlives it.
46 1 : const resultTableBytes = new WeakMap<arrow.Table, number>();
47 :
48 : /// The bytes the buffers of a result table hold in memory. Sums the buffer sizes of every column of every batch without
49 : /// reading a row, and adds every dictionary once since the batches of a column share it.
50 1 : export function getResultTableBytes(table: arrow.Table): number {
51 1 : const cached = resultTableBytes.get(table);
52 1 : if (cached !== undefined) return cached;
53 1 : const dictionaries = new Set<arrow.Vector>();
54 1 : const collectDictionaries = (data: arrow.Data) => {
55 1 : if (data.dictionary != null) dictionaries.add(data.dictionary);
56 1 : for (const child of data.children) collectDictionaries(child);
57 1 : };
58 1 : let bytes = 0;
59 1 : for (const batch of table.batches) {
60 1 : bytes += batch.data.byteLength;
61 1 : collectDictionaries(batch.data);
62 1 : }
63 1 : for (const dictionary of dictionaries) {
64 1 : for (const data of dictionary.data) bytes += data.byteLength;
65 : }
66 1 : resultTableBytes.set(table, bytes);
67 1 : return bytes;
68 1 : }
69 :
70 : /// The detail of a result header: the row count followed by the size of the result table, "3 rows, 1.2 kB".
71 : /// Null while the row count is unknown, only the row count while the result table is not there yet.
72 1 : export function formatResultDetail(rowCount: string | null, resultTable: arrow.Table | null): string | null {
73 1 : if (rowCount == null || resultTable == null) return rowCount;
74 1 : return `${rowCount}, ${formatBytes(getResultTableBytes(resultTable))}`;
75 1 : }
76 :
77 : interface HeaderProps {
78 : query: QueryExecutionState;
79 : /// The rows the table below shows at most, a preview of more rows counts as "6 of 1000 rows"
80 : maxRows?: number;
81 : /// Open the full result table, makes the header clickable
82 : onShowTable?: () => void;
83 : dependencies?: QueryResultHeaderDependencies;
84 : }
85 :
86 : /// The header over a result table: the title, the row count, the search, the download and the actions.
87 : /// The feed entries, the dashboard cards and the dataflow boxes show the same header.
88 1 : export function QueryResultHeader(props: HeaderProps): React.ReactElement {
89 1 : const dependencies = props.dependencies ?? DEFAULT_HEADER_DEPENDENCIES;
90 1 : const counts = dependencies.useQueryResultRowCounts(props.query);
91 1 : const maxRows = props.maxRows ?? Infinity;
92 1 : const displayedRows = counts.matchingRows ?? counts.totalRows;
93 1 : return (
94 1 : <TabHeader
95 1 : title="Query Results"
96 1 : detail={formatResultDetail(formatResultRowCount(counts, maxRows), props.query.resultTable)}
97 1 : onClick={props.onShowTable}
98 1 : detailIsLink={displayedRows != null && displayedRows > maxRows}
99 1 : actions={(
100 1 : <>
101 1 : <dependencies.QueryResultToolbar query={props.query} />
102 1 : <dependencies.QueryResultDownloadButton query={props.query} />
103 1 : <dependencies.QueryResultActionMenu query={props.query} />
104 1 : </>
105 : )}
106 1 : />
107 : );
108 1 : }
109 :
110 : interface PanelProps {
111 : query: QueryExecutionState | null;
112 : cellBackground?: string;
113 : /// Open the full result table, makes the row count in the header and the column names clickable
114 : onShowTable?: () => void;
115 : dependencies?: QueryResultPanelDependencies;
116 : }
117 :
118 : export interface QueryResultPanelDependencies extends QueryResultHeaderDependencies {
119 : QueryResultView: typeof QueryResultView;
120 : }
121 :
122 1 : const DEFAULT_PANEL_DEPENDENCIES: QueryResultPanelDependencies = {
123 1 : ...DEFAULT_HEADER_DEPENDENCIES,
124 1 : QueryResultView,
125 1 : };
126 :
127 : /// A result table with its header that fills its host, for the table renderer of a script in the dashboard and the dataflow.
128 : /// The table shows the rows that fit into the host and does not scroll, like the preview of a feed entry. It fills the host,
129 : /// a last row that does not fit is cut off at the bottom.
130 1 : export function QueryResultPanel(props: PanelProps): React.ReactElement {
131 1 : const dependencies = props.dependencies ?? DEFAULT_PANEL_DEPENDENCIES;
132 1 : const bodyRef = React.useRef<HTMLDivElement | null>(null);
133 1 : const [bodyHeight, setBodyHeight] = React.useState<number | null>(null);
134 : // Measured before the first paint, the table never shows more rows than fit
135 1 : React.useLayoutEffect(() => {
136 1 : const body = bodyRef.current;
137 1 : if (body == null) return;
138 1 : setBodyHeight(body.clientHeight);
139 1 : const observer = new ResizeObserver(entries => setBodyHeight(entries[0]?.contentRect.height ?? body.clientHeight));
140 1 : observer.observe(body);
141 1 : return () => observer.disconnect();
142 1 : }, []);
143 1 : const maxRows = bodyHeight == null ? 0 : fittingRowCount(bodyHeight);
144 1 : return (
145 1 : <div className={styles.panel}>
146 : {/* The header stays while a query runs, like in the feed: the table may show before the query succeeds and must
147 : not lose the height of the header then */}
148 1 : {props.query != null && <QueryResultHeader query={props.query} maxRows={maxRows} onShowTable={props.onShowTable} dependencies={dependencies} />}
149 1 : <div ref={bodyRef} className={styles.panel_body}>
150 : {/* Before the first run and while a query has no rows yet, the body shows the logo with the status below it */}
151 1 : {props.query == null || (props.query.resultTable == null && props.query.status !== QueryExecutionStatus.SUCCEEDED)
152 1 : ? <QueryStatusPlaceholder query={props.query} />
153 1 : : <dependencies.QueryResultView
154 1 : query={props.query}
155 1 : debugMode={false}
156 1 : maxRows={maxRows}
157 1 : columnHeader={TableColumnHeader.OnlyColumnName}
158 1 : cellBackground={props.cellBackground}
159 1 : onShowTable={props.onShowTable}
160 1 : fitHeight={false}
161 1 : fillHeight
162 1 : compact
163 1 : />}
164 1 : </div>
165 1 : </div>
166 : );
167 1 : }
|