feat(sqllab): Improved query status indicator bar (#36936)

This commit is contained in:
JUST.in DO IT
2026-01-26 08:57:52 -08:00
committed by GitHub
parent 647f21c26a
commit 0fd528c7af
8 changed files with 415 additions and 93 deletions

View File

@@ -35,7 +35,9 @@ export function Timer({
status = 'success',
}: TimerProps) {
const theme = useTheme();
const [clockStr, setClockStr] = useState('00:00:00.00');
const [clockStr, setClockStr] = useState(
startTime && endTime ? fDuration(startTime, endTime) : '00:00:00.00',
);
const timer = useRef<ReturnType<typeof setInterval>>();
useEffect(() => {

View File

@@ -316,6 +316,7 @@ export type Query = {
errorMessage: string | null;
extra: {
progress: string | null;
progress_text?: string;
errors?: SupersetError[];
};
id: string;

View File

@@ -392,7 +392,7 @@ export function startQuery(query: Query, runPreviewOnly?: boolean) {
id: query.id ? query.id : nanoid(11),
progress: 0,
startDttm: now(),
state: query.runAsync ? 'pending' : 'running',
state: 'pending',
cached: false,
});
return { type: START_QUERY, query, runPreviewOnly } as const;

View File

@@ -0,0 +1,161 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { isValidElement } from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import { QueryState, type QueryResponse } from '@superset-ui/core';
import QueryStatusBar from '.';
jest.mock('../QueryStateLabel', () => ({
__esModule: true,
default: ({ query }: { query: { state: QueryState } }) => (
<div data-test="query-state-label">{query.state}</div>
),
}));
const createMockQuery = (
overrides: Partial<QueryResponse> = {},
): QueryResponse =>
({
id: 'test-query-id',
dbId: 1,
sql: 'SELECT * FROM test',
sqlEditorId: 'test-editor',
tab: 'Test Tab',
ctas: false,
cached: false,
progress: 0,
startDttm: Date.now() - 1000,
endDttm: undefined,
state: QueryState.Running,
tempSchema: null,
tempTable: null,
userId: 1,
executedSql: null,
rows: 0,
queryLimit: 100,
catalog: null,
schema: 'test_schema',
errorMessage: null,
extra: {},
results: undefined,
...overrides,
}) as QueryResponse;
test('is valid element', () => {
const query = createMockQuery();
expect(isValidElement(<QueryStatusBar query={query} />)).toBe(true);
});
test('renders query state label', () => {
const query = createMockQuery({ state: QueryState.Running });
render(<QueryStatusBar query={query} />);
expect(screen.getByTestId('query-state-label')).toBeInTheDocument();
expect(screen.getByText('Query State:')).toBeInTheDocument();
});
test('renders elapsed time section', () => {
const query = createMockQuery({ state: QueryState.Running });
render(<QueryStatusBar query={query} />);
expect(screen.getByText('Elapsed:')).toBeInTheDocument();
});
test('renders steps for running query', () => {
const query = createMockQuery({ state: QueryState.Running, progress: 50 });
render(<QueryStatusBar query={query} />);
expect(screen.getByText('Validate query')).toBeInTheDocument();
expect(screen.getByText('Connect to engine')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('Download to client')).toBeInTheDocument();
expect(screen.getByText('Finish')).toBeInTheDocument();
});
test('renders steps for pending query', () => {
const query = createMockQuery({ state: QueryState.Pending });
render(<QueryStatusBar query={query} />);
expect(screen.getByText('Validate query')).toBeInTheDocument();
});
test('returns null when query is successful with results', () => {
const query = createMockQuery({
state: QueryState.Success,
results: {
displayLimitReached: false,
columns: [],
selected_columns: [],
expanded_columns: [],
data: [],
query: { limit: 100 },
},
});
const { container } = render(<QueryStatusBar query={query} />);
expect(container).toBeEmptyDOMElement();
});
test('displays progress percentage when available', () => {
const query = createMockQuery({
state: QueryState.Running,
progress: 75,
});
render(<QueryStatusBar query={query} />);
expect(screen.getByText('(75%)')).toBeInTheDocument();
});
test('displays progress text when available', () => {
const query = createMockQuery({
state: QueryState.Running,
progress: 50,
extra: { progress: null, progress_text: 'Processing rows' },
});
render(<QueryStatusBar query={query} />);
expect(screen.getByText('(50%, Processing rows)')).toBeInTheDocument();
});
test('displays only progress text when no percentage', () => {
const query = createMockQuery({
state: QueryState.Running,
progress: 0,
extra: { progress: null, progress_text: 'Initializing' },
});
render(<QueryStatusBar query={query} />);
expect(screen.getByText('(Initializing)')).toBeInTheDocument();
});
test('renders for failed query state', () => {
const query = createMockQuery({
state: QueryState.Failed,
errorMessage: 'Query failed',
});
render(<QueryStatusBar query={query} />);
expect(screen.getByTestId('query-state-label')).toBeInTheDocument();
});
test('renders for stopped query state', () => {
const query = createMockQuery({ state: QueryState.Stopped });
render(<QueryStatusBar query={query} />);
expect(screen.getByTestId('query-state-label')).toBeInTheDocument();
});
test('renders for fetching state', () => {
const query = createMockQuery({
state: QueryState.Fetching,
progress: 100,
});
render(<QueryStatusBar query={query} />);
expect(screen.getByText('Download to client')).toBeInTheDocument();
});

View File

@@ -0,0 +1,214 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useMemo, createContext, useContext, useRef } from 'react';
import { t, styled } from '@apache-superset/core';
import {
Flex,
Steps,
type StepsProps,
StyledSpin,
Timer,
} from '@superset-ui/core/components';
import { QueryResponse, QueryState, usePrevious } from '@superset-ui/core';
import QueryStateLabel from '../QueryStateLabel';
type QueryStatusBarProps = {
query: QueryResponse;
};
const STATE_TO_STEP: Record<string, number> = {
offline: 4,
failed: 4,
pending: 0,
fetching: 3,
running: 2,
stopped: 4,
success: 4,
};
const ERROR_STATE = [QueryState.Failed, QueryState.Stopped];
const StyledSteps = styled.div`
& .ant-steps {
margin: ${({ theme }) => theme.sizeUnit * 2}px 0;
}
`;
const ActiveDot = styled.div`
position: relative;
display: flex;
align-items: center;
justify-content: center;
&::before,
&::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${({ theme }) => theme.colorPrimary};
top: -1px;
opacity: 0;
animation: pulse 2s ease-out infinite;
}
&::after {
animation-delay: 1s;
}
@keyframes pulse {
0% {
transform: scale(0.5);
opacity: 0.8;
}
100% {
transform: scale(3);
opacity: 0;
}
}
`;
const progressContext = createContext<[number, string]>([0, '']);
const ProgressStatus = () => {
const [percent, progressText] = useContext(progressContext);
return (
<>
{percent > 0 ? (
<span>
({percent}%{progressText && `, ${progressText}`})
</span>
) : (
<>{progressText && <span>({progressText})</span>}</>
)}
</>
);
};
const ProgressSpin = () => {
const [percent] = useContext(progressContext);
return (
<>
{typeof percent === 'number' && percent > 0 && (
<StyledSpin size="small" percent={percent} />
)}
</>
);
};
const customDot: StepsProps['progressDot'] = (dot, { status }) =>
status === 'process' ? (
<ActiveDot>
<ProgressSpin />
</ActiveDot>
) : (
<>{dot}</>
);
const QueryStatusBar: FC<QueryStatusBarProps> = ({ query }) => {
const steps = [
{
title: t('Validate query'),
},
{
title: t('Connect to engine'),
},
{
title: (
<Flex align="center" gap="small">
{t('Running')}
<ProgressStatus />
</Flex>
),
},
{
title: t('Download to client'),
},
{
title: t('Finish'),
},
];
const hasError = useMemo(
() => ERROR_STATE.includes(query.state),
[query.state],
);
const prevStepRef = useRef<number>(0);
const progress = query.progress > 0 ? query.progress : undefined;
const { progress_text: progressText } = query.extra ?? {};
const state =
query.state === QueryState.Success &&
prevStepRef.current === STATE_TO_STEP[QueryState.Running] &&
!query.results
? QueryState.Fetching
: query.state;
const currentIndex = STATE_TO_STEP[state] || 0;
const prevStep = usePrevious(currentIndex);
prevStepRef.current = prevStep ?? prevStepRef.current;
if (query.state === QueryState.Success && query.results) {
return null;
}
if (
query.state === QueryState.Failed &&
prevStep === STATE_TO_STEP[QueryState.Failed]
) {
return null;
}
return (
<StyledSteps>
<Flex justify="space-between">
<Flex gap="small" align="center">
<span>{t('Query State')}:</span>
<QueryStateLabel query={query} />
</Flex>
<Flex gap="small" align="center">
<span>{t('Elapsed')}:</span>
<Timer
startTime={query.startDttm}
endTime={query.endDttm}
status="default"
isRunning={currentIndex < steps.length - 2}
/>
</Flex>
</Flex>
<progressContext.Provider value={[progress ?? 0, progressText ?? '']}>
<Steps
size="small"
current={hasError ? prevStep : currentIndex}
items={steps}
status={
hasError
? 'error'
: currentIndex < steps.length - 1
? 'process'
: 'finish'
}
{...(!hasError && { progressDot: customDot })}
/>
</progressContext.Provider>
</StyledSteps>
);
};
export default QueryStatusBar;

View File

@@ -35,7 +35,6 @@ import {
cachedQuery,
failedQueryWithErrors,
queries,
runningQuery,
stoppedQuery,
initialState,
user,
@@ -85,32 +84,6 @@ const stoppedQueryState = {
},
},
};
const runningQueryState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
[runningQuery.id]: runningQuery,
},
},
};
const fetchingQueryState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
[mockedProps.queryId]: {
dbId: 1,
cached: false,
ctas: false,
id: 'ryhHUZCGb',
progress: 100,
state: 'fetching',
startDttm: Date.now() - 500,
},
},
},
};
const cachedQueryState = {
...initialState,
sqlLab: {
@@ -332,25 +305,6 @@ describe('ResultSet', () => {
expect(alert).toBeInTheDocument();
});
test('should render running/pending/fetching query', async () => {
const { getByTestId } = setup(
{ ...mockedProps, queryId: runningQuery.id },
mockStore(runningQueryState),
);
const progressBar = getByTestId('progress-bar');
expect(progressBar).toBeInTheDocument();
});
test('should render fetching w/ 100 progress query', async () => {
const { getByRole, getByText } = setup(
mockedProps,
mockStore(fetchingQueryState),
);
const loading = getByRole('status');
expect(loading).toBeInTheDocument();
expect(getByText('fetching')).toBeInTheDocument();
});
test('should render a failed query with an errors object', async () => {
const { errors } = failedQueryWithErrors;

View File

@@ -36,7 +36,6 @@ import {
Tooltip,
Input,
Label,
Loading,
} from '@superset-ui/core/components';
import {
CopyToClipboard,
@@ -62,7 +61,6 @@ import {
import { EXPLORE_CHART_DEFAULT, SqlLabRootState } from 'src/SqlLab/types';
import { mountExploreUrl } from 'src/explore/exploreUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
import ProgressBar from '@superset-ui/core/components/ProgressBar';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
import { getItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
@@ -90,7 +88,6 @@ import { makeUrl } from 'src/utils/pathUtils';
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
import ExploreResultsButton from '../ExploreResultsButton';
import HighlightedSql from '../HighlightedSql';
import QueryStateLabel from '../QueryStateLabel';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewContribution } from 'src/SqlLab/contributions';
@@ -823,34 +820,20 @@ const ResultSet = ({
}
}
let progressBar;
if (query.progress > 0) {
progressBar = (
<ProgressBar percent={parseInt(query.progress.toFixed(0), 10)} striped />
);
}
const progressMsg = query?.extra?.progress ?? null;
return (
<>
<ResultlessStyles>
<div>{!progressBar && <Loading position="normal" />}</div>
{/* show loading bar whenever progress bar is completed but needs time to render */}
<div>{query.progress === 100 && <Loading position="normal" />}</div>
<QueryStateLabel query={query} />
<div>
{progressMsg && <Alert type="success" message={progressMsg} />}
</div>
<div>{query.progress !== 100 && progressBar}</div>
{trackingUrl && <div>{trackingUrl}</div>}
</ResultlessStyles>
<ResultlessStyles>
{progressMsg && (
<Alert type="success" message={progressMsg} closable={false} />
)}
{trackingUrl && <div>{trackingUrl}</div>}
<StreamingExportModal
visible={showStreamingModal}
onCancel={handleCloseStreamingModal}
progress={progress}
/>
</>
</ResultlessStyles>
);
};

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { FC, useMemo } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { EmptyState } from '@superset-ui/core/components';
import { t } from '@apache-superset/core';
@@ -26,6 +26,7 @@ import { styled, Alert } from '@apache-superset/core/ui';
import { SqlLabRootState } from 'src/SqlLab/types';
import ResultSet from '../ResultSet';
import { LOCALSTORAGE_MAX_QUERY_AGE_MS } from '../../constants';
import QueryStatusBar from '../QueryStatusBar';
type Props = {
latestQueryId?: string;
@@ -53,10 +54,14 @@ const Results: FC<Props> = ({
({ sqlLab: { databases } }: SqlLabRootState) => databases,
shallowEqual,
);
const latestQuery = useSelector(
({ sqlLab: { queries } }: SqlLabRootState) => queries[latestQueryId || ''],
const queries = useSelector(
({ sqlLab: { queries } }: SqlLabRootState) => queries,
shallowEqual,
);
const latestQuery = useMemo(
() => queries[latestQueryId ?? ''],
[queries, latestQueryId],
);
if (
!latestQuery ||
@@ -72,30 +77,32 @@ const Results: FC<Props> = ({
);
}
if (
const hasNoStoredResults =
isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
latestQuery.state === 'success' &&
!latestQuery.resultsKey &&
!latestQuery.results
) {
return (
<Alert
type="info"
message={t('No stored results found, you need to re-run your query')}
/>
);
}
!latestQuery.results;
return (
<ResultSet
search
queryId={latestQuery.id}
database={databases[latestQuery.dbId]}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
showSql
showSqlInline
/>
<>
<QueryStatusBar key={latestQueryId} query={latestQuery} />
{hasNoStoredResults ? (
<Alert
type="info"
message={t('No stored results found, you need to re-run your query')}
/>
) : (
<ResultSet
search
queryId={latestQuery.id}
database={databases[latestQuery.dbId]}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
showSql
showSqlInline
/>
)}
</>
);
};