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

@@ -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;