LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_progress - query_progress_panel.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 90.4 % 177 160
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1