Line data Source code
1 : import * as React from 'react';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import * as styles from './query_progress_panel.module.css';
4 :
5 : import { QueryExecutionState, QueryExecutionStatus } from '../../query/query_execution_state.js';
6 : import { TabHeader } from '../../session/tab_header.js';
7 : import { QueryProfileView } from '../query_profile/query_profile_view.js';
8 : import { useQueryProfileDetail } from '../../connections/query_executor.js';
9 : import { QueryProgressFollower, type QueryProfileProgress } from '../../query/query_progress_model.js';
10 : import { createProfileLayoutConfig, rotateProfileLayoutOrientation } from '../query_profile/profile_layout_config.js';
11 : import { useDashQLCoreSetup, type DashQLSetupFn } from '../../runtime/core_provider.js';
12 : import { useFileDownloader } from '../../platform/file/file_downloader_hook.js';
13 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
14 : import { DownloadIcon, ScreenFullIcon } from '../../ui/foundations/symbol_icon.js';
15 : import { QueryProfileOverlay } from './query_profile_overlay.js';
16 : import {
17 : formatCompactBytes,
18 : formatCompactCount,
19 : formatElapsed,
20 : formatPhase,
21 : monotonicFraction,
22 : stat,
23 : workOf,
24 : type ProgressWork,
25 : } from './query_progress_model.js';
26 :
27 1 : const view = dashql.buffers.profile;
28 :
29 : /// Is a query still running?
30 1 : function isActive(status: QueryExecutionStatus): boolean {
31 1 : return status !== QueryExecutionStatus.SUCCEEDED && status !== QueryExecutionStatus.FAILED
32 1 : && status !== QueryExecutionStatus.CANCELLED;
33 1 : }
34 :
35 : /// The end of a query, null while it runs
36 1 : function finishedAt(query: QueryExecutionState): Date | null {
37 1 : return query.metrics.querySucceededAt ?? query.metrics.queryFailedAt ?? query.metrics.queryCancelledAt;
38 1 : }
39 :
40 : /// The milliseconds since the query was requested, ticking every second while the query runs
41 1 : function useElapsed(query: QueryExecutionState): number {
42 1 : const active = isActive(query.status);
43 1 : const [now, setNow] = React.useState(() => Date.now());
44 1 : React.useEffect(() => {
45 1 : if (!active) return;
46 1 : const timer = setInterval(() => setNow(Date.now()), 1000);
47 1 : return () => clearInterval(timer);
48 1 : }, [active]);
49 1 : const start = query.metrics.queryRequestedAt?.getTime() ?? now;
50 1 : const end = finishedAt(query)?.getTime() ?? now;
51 1 : return Math.max(0, end - start);
52 1 : }
53 :
54 : /// A progress fraction that never moves backwards within a query
55 1 : function useMonotonicFraction(queryId: number, work: ProgressWork | null): number | null {
56 1 : const state = React.useRef({ queryId, fraction: 0, known: false });
57 1 : if (state.current.queryId !== queryId) state.current = { queryId, fraction: 0, known: false };
58 1 : if (work != null) {
59 0 : state.current.fraction = monotonicFraction(state.current.fraction, work);
60 0 : state.current.known = true;
61 0 : }
62 1 : return state.current.known ? state.current.fraction : null;
63 1 : }
64 :
65 : /// A progress bar, indeterminate without a fraction
66 1 : function ProgressBar(props: { fraction: number | null; label: string; active: boolean }) {
67 1 : const percent = props.fraction == null ? null : Math.round(props.fraction * 100);
68 1 : return (
69 1 : <div
70 1 : className={styles.bar}
71 1 : role="progressbar"
72 1 : aria-label={props.label}
73 1 : aria-valuemin={0}
74 1 : aria-valuemax={100}
75 1 : aria-valuenow={percent ?? undefined}
76 1 : data-indeterminate={props.fraction == null && props.active}
77 : >
78 1 : <div className={styles.bar_fill} style={{ width: `${percent ?? 0}%` }} />
79 1 : </div>
80 : );
81 1 : }
82 :
83 : /// A labeled figure of the query strip
84 1 : function Figure(props: { label: string; value: string | null }) {
85 1 : if (props.value == null) return null;
86 1 : return (
87 1 : <div className={styles.figure}>
88 1 : <span className={styles.figure_label}>{props.label}</span>
89 1 : <span className={styles.figure_value}>{props.value}</span>
90 1 : </div>
91 : );
92 1 : }
93 :
94 : interface QueryProgressPanelProps {
95 : query: QueryExecutionState;
96 : /// Show the profile of the query (the plan with its stages) once the plan is known
97 : showProfile?: boolean;
98 : /// The height of the profile in pixels, 360 by default
99 : profileHeight?: number;
100 : }
101 :
102 :
103 : /// Download the recording of the progress of a query, replaying it in the profile simulator reproduces the profile
104 1 : function DownloadRecordingButton(props: { queryId: number; progress: QueryProfileProgress }) {
105 1 : const fileDownloader = useFileDownloader();
106 1 : const download = React.useCallback(() => {
107 0 : const text = JSON.stringify(props.progress.recording());
108 0 : void fileDownloader.downloadBufferAsFile(new TextEncoder().encode(text), `query-${props.queryId}-profile.json`);
109 1 : }, [fileDownloader, props.progress, props.queryId]);
110 1 : return (
111 1 : <IconButton
112 1 : variant={ButtonVariant.Invisible}
113 1 : size={ButtonSize.Small}
114 1 : aria-label="Download the profile recording"
115 1 : description="Download the recording of the progress, the profile simulator replays it"
116 1 : data-testid="download-profile-recording"
117 1 : onClick={download}
118 : >
119 1 : <DownloadIcon size={16} />
120 1 : </IconButton>
121 : );
122 1 : }
123 :
124 : /// The progress of a query with its plan laid out in the other orientation, while rotated. The progress model of
125 : /// the query keeps its layout, a follower of the panel applies the recorded payloads again. Without core, e.g.
126 : /// while it loads, the progress stays as it is.
127 1 : function useRotatedProgress(progress: QueryProfileProgress, rotated: boolean): QueryProfileProgress {
128 1 : const coreSetup = useDashQLCoreSetup() as DashQLSetupFn | null;
129 1 : const [core, setCore] = React.useState<dashql.DashQL | null>(null);
130 1 : React.useEffect(() => {
131 1 : if (!rotated || core != null || coreSetup == null) return;
132 0 : let cancelled = false;
133 0 : coreSetup('query_progress').then(
134 0 : instance => { if (!cancelled) setCore(instance); },
135 0 : () => { /* The plan stays in its orientation */ },
136 0 : );
137 0 : return () => { cancelled = true; };
138 1 : }, [rotated, core, coreSetup]);
139 :
140 1 : const follower = React.useRef<QueryProgressFollower | null>(null);
141 1 : React.useEffect(() => () => {
142 1 : follower.current?.destroy();
143 1 : follower.current = null;
144 1 : }, []);
145 1 : return React.useMemo(() => {
146 1 : if (!rotated || core == null) {
147 1 : follower.current?.destroy();
148 1 : follower.current = null;
149 1 : return progress;
150 1 : }
151 1 : const orientation = progress.structure.read().layoutConfig()?.input()?.orientation()
152 0 : ?? dashql.buffers.profile.ProfileLayoutOrientation.LEFT_TO_RIGHT;
153 1 : follower.current ??= new QueryProgressFollower(core, createProfileLayoutConfig(rotateProfileLayoutOrientation(orientation)));
154 1 : return follower.current.follow(progress) ?? progress;
155 1 : }, [core, progress, rotated]);
156 1 : }
157 :
158 : /// The profile of a query with the progress of its stages on their contours, once the plan is known
159 1 : export function ProfileProgressDetails(props: { progress: QueryProfileProgress; height?: number | string }) {
160 1 : const [rotated, setRotated] = React.useState(false);
161 1 : const progress = useRotatedProgress(props.progress, rotated);
162 1 : const rotate = React.useCallback(() => setRotated(value => !value), []);
163 1 : const hasOperators = React.useMemo(() => progress.structure.read().operatorsLength() > 0, [progress.structure]);
164 1 : if (!hasOperators) return null;
165 1 : return (
166 1 : <div className={styles.profile} style={props.height == null ? undefined : { height: props.height }} data-testid="query-progress-profile">
167 1 : <QueryProfileView plan={progress.structure} execution={progress.execution} onRotate={rotate} showProgress fill />
168 1 : </div>
169 : );
170 1 : }
171 :
172 : /// The progress of a query: its phase, a progress bar, throughput and the profile with the progress of its stages
173 1 : export function QueryProgressPanel(props: QueryProgressPanelProps) {
174 1 : const { query } = props;
175 : // The connector fetches the plan and its statistics only while a profile is shown
176 1 : useQueryProfileDetail(query.queryId, props.showProfile === true);
177 1 : const progress = query.latestProgressUpdate;
178 1 : const profileProgress = progress?.profileProgress ?? null;
179 1 : const queryState = profileProgress?.execution.query ?? null;
180 1 : const active = isActive(query.status);
181 1 : const elapsedMs = useElapsed(query);
182 1 : const work = workOf(queryState);
183 1 : const fraction = useMonotonicFraction(query.queryId, work);
184 1 : const structure = profileProgress?.structure ?? null;
185 : // Trino counts work in splits, other engines report a fraction
186 1 : const countsSplits = React.useMemo(() => structure?.read().engine() === dashql.buffers.profile.ProfileEngine.TRINO, [structure]);
187 :
188 1 : const rows = stat(queryState, view.ProfileStatsField.TUPLES_IN, s => s.tuplesIn) ?? progress?.rowsProcessed ?? null;
189 1 : const cpuUs = stat(queryState, view.ProfileStatsField.CPU_TIME, s => s.cpuTimeUs);
190 1 : const memory = stat(queryState, view.ProfileStatsField.MEMORY_BYTES, s => s.memoryBytes);
191 1 : const spilled = stat(queryState, view.ProfileStatsField.SPILLED_BYTES, s => s.spilledBytes);
192 1 : const phase = active ? (progress?.phase ?? null) : null;
193 1 : const phaseLabel = phase != null ? formatPhase(phase) : QueryExecutionStatus[query.status] != null
194 0 : ? formatPhase(QueryExecutionStatus[query.status]) : null;
195 1 : const seconds = elapsedMs / 1000;
196 1 : const [profileOpen, setProfileOpen] = React.useState(false);
197 1 : const showsProfile = props.showProfile === true && profileProgress != null;
198 1 : const hasOperators = React.useMemo(() => (structure?.read().operatorsLength() ?? 0) > 0, [structure]);
199 :
200 1 : return (
201 1 : <div className={styles.container} data-testid="query-progress-panel">
202 1 : <TabHeader
203 1 : title="Progress"
204 1 : detail={phaseLabel}
205 1 : actions={showsProfile
206 : ? (
207 1 : <>
208 1 : <DownloadRecordingButton queryId={query.queryId} progress={profileProgress} />
209 1 : {hasOperators && <IconButton
210 1 : variant={ButtonVariant.Invisible}
211 1 : size={ButtonSize.Small}
212 1 : aria-label="Open the query plan"
213 1 : description="Open the plan with the progress of its stages in a large card"
214 1 : data-testid="open-query-profile"
215 1 : onClick={() => setProfileOpen(true)}
216 : >
217 1 : <ScreenFullIcon size={16} />
218 1 : </IconButton>}
219 1 : </>
220 : )
221 0 : : undefined}
222 1 : />
223 1 : <div className={styles.strip}>
224 1 : <ProgressBar
225 1 : fraction={!active && query.status === QueryExecutionStatus.SUCCEEDED ? 1 : fraction}
226 1 : label="Query progress"
227 1 : active={active}
228 1 : />
229 1 : <div className={styles.figures}>
230 1 : <Figure label="Elapsed" value={formatElapsed(elapsedMs)} />
231 1 : <Figure label="Splits" value={work == null || !countsSplits ? null : `${formatCompactCount(work.done)} / ${formatCompactCount(work.total)}`} />
232 1 : <Figure label="Rows" value={rows == null ? null : formatCompactCount(rows)} />
233 1 : <Figure label="Rows/s" value={rows == null || seconds < 0.5 ? null : formatCompactCount(rows / seconds)} />
234 1 : <Figure label="CPU" value={cpuUs == null ? null : formatElapsed(cpuUs / 1000)} />
235 1 : <Figure label="Memory" value={memory == null ? null : formatCompactBytes(memory)} />
236 1 : <Figure label="Spilled" value={spilled == null || spilled === 0 ? null : formatCompactBytes(spilled)} />
237 1 : </div>
238 1 : </div>
239 1 : {showsProfile && <ProfileProgressDetails progress={profileProgress} height={props.profileHeight} />}
240 : {/* The card follows the progress of the query while it runs */}
241 1 : {showsProfile && profileOpen && (
242 0 : <QueryProfileOverlay onClose={() => setProfileOpen(false)}>
243 0 : <ProfileProgressDetails progress={profileProgress} height="100%" />
244 0 : </QueryProfileOverlay>
245 : )}
246 1 : </div>
247 : );
248 1 : }
249 :
250 : /// Does a query have structured progress to show?
251 1 : export function hasQueryProgress(query: QueryExecutionState | null): boolean {
252 1 : return query?.latestProgressUpdate?.profileProgress != null;
253 1 : }
|