mirror of
https://github.com/apache/superset.git
synced 2026-04-16 06:34:52 +00:00
feat(sqllab): Improved query status indicator bar (#36936)
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -316,6 +316,7 @@ export type Query = {
|
||||
errorMessage: string | null;
|
||||
extra: {
|
||||
progress: string | null;
|
||||
progress_text?: string;
|
||||
errors?: SupersetError[];
|
||||
};
|
||||
id: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
214
superset-frontend/src/SqlLab/components/QueryStatusBar/index.tsx
Normal file
214
superset-frontend/src/SqlLab/components/QueryStatusBar/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user