feat: update timeout error UX (#10274)

This commit is contained in:
Erik Ritter 2020-07-20 15:32:17 -07:00 committed by GitHub
parent d92cb66f60
commit 5fa4680447
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 557 additions and 65 deletions

View File

@ -164,6 +164,7 @@ Contents
gallery gallery
druid druid
misc misc
issue_code_reference
faq faq

View File

@ -0,0 +1,39 @@
.. 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.
Issue Code Reference
====================
This page lists issue codes that may be displayed in Superset and provides additional context.
Issue 1000
""""""""""
.. code-block:: text
The datasource is too large to query.
It's likely your datasource has grown too large to run the current query, and is timing out. You can resolve this by reducing the size of your datasource or by modifying your query to only process a subset of your data.
Issue 1001
""""""""""
.. code-block:: text
The database is under an unusual load.
Your query may have timed out because of unusually high load on the database engine. You can make your query simpler, or wait until the database is under less load and try again.

View File

@ -0,0 +1,21 @@
<!--
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.
-->
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4099 9L16.7099 2.71C17.1021 2.31788 17.1021 1.68212 16.7099 1.29C16.3178 0.89788 15.6821 0.89788 15.2899 1.29L8.99994 7.59L2.70994 1.29C2.31782 0.89788 1.68206 0.89788 1.28994 1.29C0.897817 1.68212 0.897817 2.31788 1.28994 2.71L7.58994 9L1.28994 15.29C1.10063 15.4778 0.994141 15.7334 0.994141 16C0.994141 16.2666 1.10063 16.5222 1.28994 16.71C1.47771 16.8993 1.7333 17.0058 1.99994 17.0058C2.26658 17.0058 2.52217 16.8993 2.70994 16.71L8.99994 10.41L15.2899 16.71C15.4777 16.8993 15.7333 17.0058 15.9999 17.0058C16.2666 17.0058 16.5222 16.8993 16.7099 16.71C16.8993 16.5222 17.0057 16.2666 17.0057 16C17.0057 15.7334 16.8993 15.4778 16.7099 15.29L10.4099 9Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -156,7 +156,7 @@ describe('chart actions', () => {
}); });
}); });
it('should CHART_UPDATE_TIMEOUT action upon query timeout', () => { it('should dispatch CHART_UPDATE_FAILED action upon query timeout', () => {
const unresolvingPromise = new Promise(() => {}); const unresolvingPromise = new Promise(() => {});
fetchMock.post(MOCK_URL, () => unresolvingPromise, { fetchMock.post(MOCK_URL, () => unresolvingPromise, {
overwriteRoutes: true, overwriteRoutes: true,
@ -169,7 +169,7 @@ describe('chart actions', () => {
// chart update, trigger query, update form data, fail // chart update, trigger query, update form data, fail
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.callCount).toBe(5); expect(dispatch.callCount).toBe(5);
expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_TIMEOUT); expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_FAILED);
setupDefaultFetchMock(); setupDefaultFetchMock();
}); });
}); });

View File

@ -40,7 +40,21 @@ describe('chart reducers', () => {
it('should update endtime on timeout', () => { it('should update endtime on timeout', () => {
const newState = chartReducer( const newState = chartReducer(
charts, charts,
actions.chartUpdateTimeout('timeout', 60, chartKey), actions.chartUpdateFailed(
{
statusText: 'timeout',
error: 'Request timed out',
errors: [
{
error_type: 'FRONTEND_TIMEOUT_ERROR',
extra: { timeout: 1 },
level: 'error',
message: 'Request timed out',
},
],
},
chartKey,
),
); );
expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0); expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0);
expect(newState[chartKey].chartStatus).toEqual('failed'); expect(newState[chartKey].chartStatus).toEqual('failed');

View File

@ -217,11 +217,14 @@ export default class ResultSet extends React.PureComponent<
return <Alert bsStyle="warning">Query was stopped</Alert>; return <Alert bsStyle="warning">Query was stopped</Alert>;
} else if (query.state === 'failed') { } else if (query.state === 'failed') {
return ( return (
<ErrorMessageWithStackTrace <div className="result-set-error-message">
error={query.errors?.[0]} <ErrorMessageWithStackTrace
message={query.errorMessage || undefined} error={query?.errors?.[0]}
link={query.link} message={query.errorMessage || undefined}
/> link={query.link}
source="sqllab"
/>
</div>
); );
} else if (query.state === 'success' && query.ctas) { } else if (query.state === 'success' && query.ctas) {
const { tempSchema, tempTable } = query; const { tempSchema, tempTable } = query;

View File

@ -409,6 +409,10 @@ div.tablePopover {
padding-right: 8px; padding-right: 8px;
} }
.result-set-error-message {
padding-top: 16px;
}
.filterable-table-container { .filterable-table-container {
margin-top: 48px; margin-top: 48px;
} }

View File

@ -49,6 +49,7 @@ const propTypes = {
timeout: PropTypes.number, timeout: PropTypes.number,
vizType: PropTypes.string.isRequired, vizType: PropTypes.string.isRequired,
triggerRender: PropTypes.bool, triggerRender: PropTypes.bool,
owners: PropTypes.arrayOf(PropTypes.string),
// state // state
chartAlert: PropTypes.string, chartAlert: PropTypes.string,
chartStatus: PropTypes.string, chartStatus: PropTypes.string,
@ -139,12 +140,26 @@ class Chart extends React.PureComponent {
} }
renderErrorMessage() { renderErrorMessage() {
const { chartAlert, chartStackTrace, queryResponse } = this.props; const {
chartAlert,
chartStackTrace,
dashboardId,
owners,
queryResponse,
} = this.props;
const error = queryResponse?.errors?.[0];
if (error) {
const extra = error.extra || {};
extra.owners = owners;
error.extra = extra;
}
return ( return (
<ErrorMessageWithStackTrace <ErrorMessageWithStackTrace
error={queryResponse?.errors?.[0]} error={error}
message={chartAlert || queryResponse?.message} message={chartAlert || queryResponse?.message}
link={queryResponse ? queryResponse.link : null} link={queryResponse ? queryResponse.link : null}
source={dashboardId ? 'dashboard' : 'explore'}
stackTrace={chartStackTrace} stackTrace={chartStackTrace}
/> />
); );

View File

@ -62,11 +62,6 @@ export function chartUpdateStopped(key) {
return { type: CHART_UPDATE_STOPPED, key }; return { type: CHART_UPDATE_STOPPED, key };
} }
export const CHART_UPDATE_TIMEOUT = 'CHART_UPDATE_TIMEOUT';
export function chartUpdateTimeout(statusText, timeout, key) {
return { type: CHART_UPDATE_TIMEOUT, statusText, timeout, key };
}
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED'; export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
export function chartUpdateFailed(queryResponse, key) { export function chartUpdateFailed(queryResponse, key) {
return { type: CHART_UPDATE_FAILED, queryResponse, key }; return { type: CHART_UPDATE_FAILED, queryResponse, key };
@ -391,19 +386,16 @@ export function exploreJSON(
}), }),
); );
}; };
if (response.name === 'AbortError') {
if (response.statusText === 'timeout') {
appendErrorLog('timeout');
return dispatch(
chartUpdateTimeout(response.statusText, timeout, key),
);
} else if (response.name === 'AbortError') {
appendErrorLog('abort'); appendErrorLog('abort');
return dispatch(chartUpdateStopped(key)); return dispatch(chartUpdateStopped(key));
} }
return getClientErrorObject(response).then(parsedResponse => { return getClientErrorObject(response).then(parsedResponse => {
// query is processed, but error out. if (response.statusText === 'timeout') {
appendErrorLog(parsedResponse.error, parsedResponse.is_cached); appendErrorLog('timeout');
} else {
appendErrorLog(parsedResponse.error, parsedResponse.is_cached);
}
return dispatch(chartUpdateFailed(parsedResponse, key)); return dispatch(chartUpdateFailed(parsedResponse, key));
}); });
}); });

View File

@ -85,21 +85,6 @@ export default function chartReducer(charts = {}, action) {
), ),
}; };
}, },
[actions.CHART_UPDATE_TIMEOUT](state) {
return {
...state,
chartStatus: 'failed',
chartAlert: `${t('Query timeout')} - ${t(
`visualization queries are set to timeout at ${action.timeout} seconds. `,
)}${t(
'Perhaps your data has grown, your database is under unusual load, ' +
'or you are simply querying a data source that is too large ' +
'to be processed within the timeout range. ' +
'If that is the case, we recommend that you summarize your data further.',
)}`,
chartUpdateEndTime: now(),
};
},
[actions.CHART_UPDATE_FAILED](state) { [actions.CHART_UPDATE_FAILED](state) {
return { return {
...state, ...state,

View File

@ -22,13 +22,14 @@ import { Alert, Collapse } from 'react-bootstrap';
import { t } from '@superset-ui/translation'; import { t } from '@superset-ui/translation';
import getErrorMessageComponentRegistry from './getErrorMessageComponentRegistry'; import getErrorMessageComponentRegistry from './getErrorMessageComponentRegistry';
import { SupersetError } from './types'; import { SupersetError, ErrorSource } from './types';
type Props = { type Props = {
error?: SupersetError; error?: SupersetError;
link?: string; link?: string;
message?: string; message?: string;
stackTrace?: string; stackTrace?: string;
source?: ErrorSource;
}; };
export default function ErrorMessageWithStackTrace({ export default function ErrorMessageWithStackTrace({
@ -36,6 +37,7 @@ export default function ErrorMessageWithStackTrace({
message, message,
link, link,
stackTrace, stackTrace,
source,
}: Props) { }: Props) {
const [showStackTrace, setShowStackTrace] = useState(false); const [showStackTrace, setShowStackTrace] = useState(false);
@ -45,7 +47,7 @@ export default function ErrorMessageWithStackTrace({
error.error_type, error.error_type,
); );
if (ErrorMessageComponent) { if (ErrorMessageComponent) {
return <ErrorMessageComponent error={error} />; return <ErrorMessageComponent error={error} source={source} />;
} }
} }

View File

@ -0,0 +1,39 @@
/**
* 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 React from 'react';
interface IssueCodeProps {
code: number;
message: string;
}
export default function IssueCode({ code, message }: IssueCodeProps) {
return (
<>
{message}{' '}
<a
href={`https://superset.apache.org/issue_code_reference.html#issue-${code}`}
rel="noopener noreferrer"
target="_blank"
>
<i className="fa fa-external-link" />
</a>
</>
);
}

View File

@ -0,0 +1,248 @@
/**
* 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 React, { useState } from 'react';
import { Modal } from 'react-bootstrap';
import { styled, supersetTheme } from '@superset-ui/style';
import { t, tn } from '@superset-ui/translation';
import { noOp } from 'src/utils/common';
import Icon from '../Icon';
import Button from '../../views/datasetList/Button';
import { ErrorMessageComponentProps } from './types';
import CopyToClipboard from '../CopyToClipboard';
import IssueCode from './IssueCode';
const ErrorAlert = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.colors.error.light2};
border-radius: ${({ theme }) => theme.borderRadius}px;
border: 1px solid ${({ theme }) => theme.colors.error.base};
color: ${({ theme }) => theme.colors.error.dark2};
padding: ${({ theme }) => 2 * theme.gridUnit}px;
width: 100%;
.top-row {
display: flex;
justify-content: space-between;
}
.error-body {
padding-top: ${({ theme }) => theme.gridUnit}px;
padding-left: ${({ theme }) => 8 * theme.gridUnit}px;
}
.icon {
margin-right: ${({ theme }) => 2 * theme.gridUnit}px;
}
.link {
color: ${({ theme }) => theme.colors.error.dark2};
text-decoration: underline;
}
`;
const ErrorModal = styled(Modal)`
color: ${({ theme }) => theme.colors.error.dark2};
.icon {
margin-right: ${({ theme }) => 2 * theme.gridUnit}px;
}
.header {
align-items: center;
background-color: ${({ theme }) => theme.colors.error.light2};
display: flex;
justify-content: space-between;
font-size: ${({ theme }) => theme.typography.sizes.l}px;
// Remove clearfix hack as Superset is only used on modern browsers
::before,
::after {
content: unset;
}
}
`;
const LeftSideContent = styled.div`
align-items: center;
display: flex;
`;
interface TimeoutErrorExtra {
issue_codes: {
code: number;
message: string;
}[];
owners?: string[];
timeout: number;
}
function TimeoutErrorMessage({
error,
source,
}: ErrorMessageComponentProps<TimeoutErrorExtra>) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMessageExpanded, setIsMessageExpanded] = useState(false);
const { extra } = error;
const isVisualization = (['dashboard', 'explore'] as (
| string
| undefined
)[]).includes(source);
const isExpandable = (['explore', 'sqllab'] as (
| string
| undefined
)[]).includes(source);
const title = isVisualization
? tn(
'Were having trouble loading this visualization. Queries are set to timeout after %s second.',
'Were having trouble loading this visualization. Queries are set to timeout after %s seconds.',
extra.timeout,
extra.timeout,
)
: tn(
'Were having trouble loading these results. Queries are set to timeout after %s second.',
'Were having trouble loading these results. Queries are set to timeout after %s seconds.',
extra.timeout,
extra.timeout,
);
const message = (
<>
<p>
{t('This may be triggered by:')}
<br />
{extra.issue_codes
.map<React.ReactNode>(issueCode => <IssueCode {...issueCode} />)
.reduce((prev, curr) => [prev, <br />, curr])}
</p>
{isVisualization && extra.owners && (
<>
<br />
<p>
{tn(
'Please reach out to the Chart Owner for assistance.',
'Please reach out to the Chart Owners for assistance.',
extra.owners.length,
)}
</p>
<p>
{tn(
'Chart Owner: %s',
'Chart Owners: %s',
extra.owners.length,
extra.owners.join(', '),
)}
</p>
</>
)}
</>
);
const copyText = `${title}
${t('This may be triggered by:')}
${extra.issue_codes.map(issueCode => issueCode.message).join('\n')}`;
return (
<ErrorAlert>
<div className="top-row">
<LeftSideContent>
<Icon
className="icon"
name="error"
color={supersetTheme.colors.error.base}
/>
<strong>{t('Timeout Error')}</strong>
</LeftSideContent>
{!isExpandable && (
<a href="#" className="link" onClick={() => setIsModalOpen(true)}>
{t('See More')}
</a>
)}
</div>
{isExpandable ? (
<div className="error-body">
<p>{title}</p>
{!isMessageExpanded && (
<a
href="#"
className="link"
onClick={() => setIsMessageExpanded(true)}
>
{t('See More')}
</a>
)}
{isMessageExpanded && (
<>
<br />
{message}
<a
href="#"
className="link"
onClick={() => setIsMessageExpanded(false)}
>
{t('See Less')}
</a>
</>
)}
</div>
) : (
<ErrorModal show={isModalOpen} onHide={() => setIsModalOpen(false)}>
<Modal.Header className="header">
<LeftSideContent>
<Icon
className="icon"
name="error"
color={supersetTheme.colors.error.base}
/>
<div className="title">{t('Timeout Error')}</div>
</LeftSideContent>
<span
role="button"
tabIndex={0}
onClick={() => setIsModalOpen(false)}
>
<Icon name="close" />
</span>
</Modal.Header>
<Modal.Body>
<p>{title}</p>
<br />
{message}
</Modal.Body>
<Modal.Footer>
<CopyToClipboard
text={copyText}
shouldShowText={false}
wrapped={false}
copyNode={<Button onClick={noOp}>{t('Copy Message')}</Button>}
/>
<Button bsStyle="primary" onClick={() => setIsModalOpen(false)}>
{t('Close')}
</Button>
</Modal.Footer>
</ErrorModal>
)}
</ErrorAlert>
);
}
export default TimeoutErrorMessage;

View File

@ -37,6 +37,9 @@ export const ErrorTypeEnum = {
TABLE_SECURITY_ACCESS_ERROR: 'TABLE_SECURITY_ACCESS_ERROR', TABLE_SECURITY_ACCESS_ERROR: 'TABLE_SECURITY_ACCESS_ERROR',
DATASOURCE_SECURITY_ACCESS_ERROR: 'DATASOURCE_SECURITY_ACCESS_ERROR', DATASOURCE_SECURITY_ACCESS_ERROR: 'DATASOURCE_SECURITY_ACCESS_ERROR',
MISSING_OWNERSHIP_ERROR: 'MISSING_OWNERSHIP_ERROR', MISSING_OWNERSHIP_ERROR: 'MISSING_OWNERSHIP_ERROR',
// Other errors
BACKEND_TIMEOUT_ERROR: 'BACKEND_TIMEOUT_ERROR',
} as const; } as const;
type ValueOf<T> = T[keyof T]; type ValueOf<T> = T[keyof T];
@ -46,15 +49,20 @@ export type ErrorType = ValueOf<typeof ErrorTypeEnum>;
// Keep in sync with superset/views/errors.py // Keep in sync with superset/views/errors.py
export type ErrorLevel = 'info' | 'warning' | 'error'; export type ErrorLevel = 'info' | 'warning' | 'error';
export type SupersetError = { export type ErrorSource = 'dashboard' | 'explore' | 'sqllab';
export type SupersetError<ExtraType = Record<string, any> | null> = {
error_type: ErrorType; error_type: ErrorType;
extra: Record<string, any> | null; extra: ExtraType;
level: ErrorLevel; level: ErrorLevel;
message: string; message: string;
}; };
export type ErrorMessageComponentProps = { export type ErrorMessageComponentProps<
error: SupersetError; ExtraType = Record<string, any> | null
> = {
error: SupersetError<ExtraType>;
source?: ErrorSource;
}; };
export type ErrorMessageComponent = React.ComponentType< export type ErrorMessageComponent = React.ComponentType<

View File

@ -17,12 +17,12 @@
* under the License. * under the License.
*/ */
import React, { SVGProps } from 'react'; import React, { SVGProps } from 'react';
import styled from '@superset-ui/style';
import { ReactComponent as CancelXIcon } from 'images/icons/cancel-x.svg'; import { ReactComponent as CancelXIcon } from 'images/icons/cancel-x.svg';
import { ReactComponent as CheckIcon } from 'images/icons/check.svg'; import { ReactComponent as CheckIcon } from 'images/icons/check.svg';
import { ReactComponent as CheckboxHalfIcon } from 'images/icons/checkbox-half.svg'; import { ReactComponent as CheckboxHalfIcon } from 'images/icons/checkbox-half.svg';
import { ReactComponent as CheckboxOffIcon } from 'images/icons/checkbox-off.svg'; import { ReactComponent as CheckboxOffIcon } from 'images/icons/checkbox-off.svg';
import { ReactComponent as CheckboxOnIcon } from 'images/icons/checkbox-on.svg'; import { ReactComponent as CheckboxOnIcon } from 'images/icons/checkbox-on.svg';
import { ReactComponent as CloseIcon } from 'images/icons/close.svg';
import { ReactComponent as CompassIcon } from 'images/icons/compass.svg'; import { ReactComponent as CompassIcon } from 'images/icons/compass.svg';
import { ReactComponent as DatasetPhysicalIcon } from 'images/icons/dataset_physical.svg'; import { ReactComponent as DatasetPhysicalIcon } from 'images/icons/dataset_physical.svg';
import { ReactComponent as DatasetVirtualIcon } from 'images/icons/dataset_virtual.svg'; import { ReactComponent as DatasetVirtualIcon } from 'images/icons/dataset_virtual.svg';
@ -35,12 +35,13 @@ import { ReactComponent as SortIcon } from 'images/icons/sort.svg';
import { ReactComponent as TrashIcon } from 'images/icons/trash.svg'; import { ReactComponent as TrashIcon } from 'images/icons/trash.svg';
import { ReactComponent as WarningIcon } from 'images/icons/warning.svg'; import { ReactComponent as WarningIcon } from 'images/icons/warning.svg';
type Icon = type IconName =
| 'cancel-x' | 'cancel-x'
| 'check' | 'check'
| 'checkbox-half' | 'checkbox-half'
| 'checkbox-off' | 'checkbox-off'
| 'checkbox-on' | 'checkbox-on'
| 'close'
| 'compass' | 'compass'
| 'dataset-physical' | 'dataset-physical'
| 'dataset-virtual' | 'dataset-virtual'
@ -53,7 +54,10 @@ type Icon =
| 'trash' | 'trash'
| 'warning'; | 'warning';
const iconsRegistry: { [key in Icon]: React.ComponentType } = { const iconsRegistry: Record<
IconName,
React.ComponentType<SVGProps<SVGSVGElement>>
> = {
'cancel-x': CancelXIcon, 'cancel-x': CancelXIcon,
'checkbox-half': CheckboxHalfIcon, 'checkbox-half': CheckboxHalfIcon,
'checkbox-off': CheckboxOffIcon, 'checkbox-off': CheckboxOffIcon,
@ -63,6 +67,7 @@ const iconsRegistry: { [key in Icon]: React.ComponentType } = {
'sort-asc': SortAscIcon, 'sort-asc': SortAscIcon,
'sort-desc': SortDescIcon, 'sort-desc': SortDescIcon,
check: CheckIcon, check: CheckIcon,
close: CloseIcon,
compass: CompassIcon, compass: CompassIcon,
error: ErrorIcon, error: ErrorIcon,
pencil: PencilIcon, pencil: PencilIcon,
@ -72,14 +77,12 @@ const iconsRegistry: { [key in Icon]: React.ComponentType } = {
warning: WarningIcon, warning: WarningIcon,
}; };
interface IconProps extends SVGProps<SVGSVGElement> { interface IconProps extends SVGProps<SVGSVGElement> {
name: Icon; name: IconName;
} }
const Icon = ({ name, ...rest }: IconProps) => { const Icon = ({ name, color = '#666666', ...rest }: IconProps) => {
const Component = iconsRegistry[name]; const Component = iconsRegistry[name];
return <Component {...rest} />; return <Component color={color} {...rest} />;
}; };
export default styled(Icon)<{}>` export default Icon;
color: #666666;
`;

View File

@ -336,6 +336,7 @@ class Chart extends React.Component {
timeout={timeout} timeout={timeout}
triggerQuery={chart.triggerQuery} triggerQuery={chart.triggerQuery}
vizType={slice.viz_type} vizType={slice.viz_type}
owners={slice.owners}
/> />
</div> </div>
</div> </div>

View File

@ -134,6 +134,7 @@ export default function (bootstrapData) {
datasource: slice.form_data.datasource, datasource: slice.form_data.datasource,
description: slice.description, description: slice.description,
description_markeddown: slice.description_markeddown, description_markeddown: slice.description_markeddown,
owners: slice.owners,
modified: slice.modified, modified: slice.modified,
changed_on: new Date(slice.changed_on).getTime(), changed_on: new Date(slice.changed_on).getTime(),
}; };

View File

@ -68,6 +68,7 @@ export const slicePropShape = PropTypes.shape({
viz_type: PropTypes.string.isRequired, viz_type: PropTypes.string.isRequired,
description: PropTypes.string, description: PropTypes.string,
description_markeddown: PropTypes.string, description_markeddown: PropTypes.string,
owners: PropTypes.arrayOf(PropTypes.string).isRequired,
}); });
export const filterIndicatorPropShape = PropTypes.shape({ export const filterIndicatorPropShape = PropTypes.shape({

View File

@ -72,6 +72,7 @@ class ExploreChartPanel extends React.PureComponent {
errorMessage={this.props.errorMessage} errorMessage={this.props.errorMessage}
formData={this.props.form_data} formData={this.props.form_data}
onQuery={this.props.onQuery} onQuery={this.props.onQuery}
owners={this.props?.slice?.owners}
queryResponse={chart.queryResponse} queryResponse={chart.queryResponse}
refreshOverlayVisible={this.props.refreshOverlayVisible} refreshOverlayVisible={this.props.refreshOverlayVisible}
setControlValue={this.props.actions.setControlValue} setControlValue={this.props.actions.setControlValue}

View File

@ -16,9 +16,22 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import getErrorMessageComponentRegistry from 'src/components/ErrorMessage/getErrorMessageComponentRegistry';
import { ErrorTypeEnum } from 'src/components/ErrorMessage/types';
import TimeoutErrorMessage from 'src/components/ErrorMessage/TimeoutErrorMessage';
import setupErrorMessagesExtra from './setupErrorMessagesExtra'; import setupErrorMessagesExtra from './setupErrorMessagesExtra';
export default function setupErrorMessages() { export default function setupErrorMessages() {
// TODO: Register error messages to the ErrorMessageComponentRegistry once implemented const errorMessageComponentRegistry = getErrorMessageComponentRegistry();
errorMessageComponentRegistry.registerValue(
ErrorTypeEnum.FRONTEND_TIMEOUT_ERROR,
TimeoutErrorMessage,
);
errorMessageComponentRegistry.registerValue(
ErrorTypeEnum.BACKEND_TIMEOUT_ERROR,
TimeoutErrorMessage,
);
setupErrorMessagesExtra(); setupErrorMessagesExtra();
} }

View File

@ -136,3 +136,5 @@ export function applyFormattingToTabularData(data) {
/* eslint-enable no-underscore-dangle */ /* eslint-enable no-underscore-dangle */
})); }));
} }
export const noOp = () => undefined;

View File

@ -18,7 +18,10 @@
*/ */
import { SupersetClientResponse } from '@superset-ui/connection'; import { SupersetClientResponse } from '@superset-ui/connection';
import { t } from '@superset-ui/translation'; import { t } from '@superset-ui/translation';
import { SupersetError } from 'src/components/ErrorMessage/types'; import {
SupersetError,
ErrorTypeEnum,
} from 'src/components/ErrorMessage/types';
import COMMON_ERR_MESSAGES from './errorMessages'; import COMMON_ERR_MESSAGES from './errorMessages';
// The response always contains an error attribute, can contain anything from the // The response always contains an error attribute, can contain anything from the
@ -84,6 +87,38 @@ export default function getClientErrorObject(
resolve({ ...responseObject, error: errorText }); resolve({ ...responseObject, error: errorText });
}); });
}); });
} else if (
'statusText' in response &&
response.statusText === 'timeout'
) {
resolve({
...responseObject,
error: 'Request timed out',
errors: [
{
error_type: ErrorTypeEnum.FRONTEND_TIMEOUT_ERROR,
extra: {
timeout: 1,
issue_codes: [
{
code: 1000,
message: t(
'Issue 1000 - The datasource is too large to query.',
),
},
{
code: 1001,
message: t(
'Issue 1001 - The database is under an unusual load.',
),
},
],
},
level: 'error',
message: 'Request timed out',
},
],
});
} else { } else {
// fall back to Response.statusText or generic error of we cannot read the response // fall back to Response.statusText or generic error of we cannot read the response
const error = const error =

View File

@ -19,6 +19,7 @@ from enum import Enum
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from dataclasses import dataclass from dataclasses import dataclass
from flask_babel import gettext as _
class SupersetErrorType(str, Enum): class SupersetErrorType(str, Enum):
@ -46,6 +47,23 @@ class SupersetErrorType(str, Enum):
DATASOURCE_SECURITY_ACCESS_ERROR = "DATASOURCE_SECURITY_ACCESS_ERROR" DATASOURCE_SECURITY_ACCESS_ERROR = "DATASOURCE_SECURITY_ACCESS_ERROR"
MISSING_OWNERSHIP_ERROR = "MISSING_OWNERSHIP_ERROR" MISSING_OWNERSHIP_ERROR = "MISSING_OWNERSHIP_ERROR"
# Other errors
BACKEND_TIMEOUT_ERROR = "BACKEND_TIMEOUT_ERROR"
ERROR_TYPES_TO_ISSUE_CODES_MAPPING = {
SupersetErrorType.BACKEND_TIMEOUT_ERROR: [
{
"code": 1000,
"message": _("Issue 1000 - The datasource is too large to query."),
},
{
"code": 1001,
"message": _("Issue 1001 - The database is under an unusual load."),
},
]
}
class ErrorLevel(str, Enum): class ErrorLevel(str, Enum):
""" """
@ -69,3 +87,13 @@ class SupersetError:
error_type: SupersetErrorType error_type: SupersetErrorType
level: ErrorLevel level: ErrorLevel
extra: Optional[Dict[str, Any]] = None extra: Optional[Dict[str, Any]] = None
def __post_init__(self) -> None:
"""
Mutates the extra params with user facing error codes that map to backend
errors.
"""
issue_codes = ERROR_TYPES_TO_ISSUE_CODES_MAPPING.get(self.error_type)
if issue_codes:
self.extra = self.extra or {}
self.extra.update({"issue_codes": issue_codes})

View File

@ -18,7 +18,7 @@ from typing import Any, Dict, Optional
from flask_babel import gettext as _ from flask_babel import gettext as _
from superset.errors import SupersetError from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
class SupersetException(Exception): class SupersetException(Exception):
@ -37,7 +37,19 @@ class SupersetException(Exception):
class SupersetTimeoutException(SupersetException): class SupersetTimeoutException(SupersetException):
pass status = 408
def __init__(
self,
error_type: SupersetErrorType,
message: str,
level: ErrorLevel,
extra: Optional[Dict[str, Any]],
) -> None:
super(SupersetTimeoutException, self).__init__(message)
self.error = SupersetError(
error_type=error_type, message=message, level=level, extra=extra
)
class SupersetSecurityException(SupersetException): class SupersetSecurityException(SupersetException):

View File

@ -177,17 +177,20 @@ class Slice(
data["error"] = str(ex) data["error"] = str(ex)
return { return {
"cache_timeout": self.cache_timeout, "cache_timeout": self.cache_timeout,
"changed_on": self.changed_on.isoformat(),
"changed_on_humanized": self.changed_on_humanized,
"datasource": self.datasource_name, "datasource": self.datasource_name,
"description": self.description, "description": self.description,
"description_markeddown": self.description_markeddown, "description_markeddown": self.description_markeddown,
"edit_url": self.edit_url, "edit_url": self.edit_url,
"form_data": self.form_data, "form_data": self.form_data,
"modified": self.modified(),
"owners": [
f"{owner.first_name} {owner.last_name}" for owner in self.owners
],
"slice_id": self.id, "slice_id": self.id,
"slice_name": self.slice_name, "slice_name": self.slice_name,
"slice_url": self.slice_url, "slice_url": self.slice_url,
"modified": self.modified(),
"changed_on_humanized": self.changed_on_humanized,
"changed_on": self.changed_on.isoformat(),
} }
@property @property

View File

@ -79,6 +79,7 @@ from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql.type_api import Variant from sqlalchemy.sql.type_api import Variant
from sqlalchemy.types import TEXT, TypeDecorator from sqlalchemy.types import TEXT, TypeDecorator
from superset.errors import ErrorLevel, SupersetErrorType
from superset.exceptions import ( from superset.exceptions import (
CertificateException, CertificateException,
SupersetException, SupersetException,
@ -617,7 +618,12 @@ class timeout: # pylint: disable=invalid-name
self, signum: int, frame: Any self, signum: int, frame: Any
) -> None: ) -> None:
logger.error("Process timed out") logger.error("Process timed out")
raise SupersetTimeoutException(self.error_message) raise SupersetTimeoutException(
error_type=SupersetErrorType.BACKEND_TIMEOUT_ERROR,
message=self.error_message,
level=ErrorLevel.ERROR,
extra={"timeout": self.seconds},
)
def __enter__(self) -> None: def __enter__(self) -> None:
try: try:

View File

@ -48,7 +48,11 @@ from superset import (
) )
from superset.connectors.sqla import models from superset.connectors.sqla import models
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetException, SupersetSecurityException from superset.exceptions import (
SupersetException,
SupersetSecurityException,
SupersetTimeoutException,
)
from superset.models.helpers import ImportMixin from superset.models.helpers import ImportMixin
from superset.translations.utils import get_language_pack from superset.translations.utils import get_language_pack
from superset.typing import FlaskResponse from superset.typing import FlaskResponse
@ -176,6 +180,9 @@ def handle_api_exception(
return json_errors_response( return json_errors_response(
errors=[ex.error], status=ex.status, payload=ex.payload errors=[ex.error], status=ex.status, payload=ex.payload
) )
except SupersetTimeoutException as ex:
logger.warning(ex)
return json_errors_response(errors=[ex.error], status=ex.status)
except SupersetException as ex: except SupersetException as ex:
logger.exception(ex) logger.exception(ex)
return json_error_response( return json_error_response(

View File

@ -1906,7 +1906,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
) )
except SupersetTimeoutException as ex: except SupersetTimeoutException as ex:
logger.exception(ex) logger.exception(ex)
return json_error_response(timeout_msg) return json_errors_response([ex.error])
except Exception as ex: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
return json_error_response(utils.error_msg_from_exception(ex)) return json_error_response(utils.error_msg_from_exception(ex))
@ -2151,6 +2151,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
:param rendered_query: The rendered query (included templates) :param rendered_query: The rendered query (included templates)
:param query: The query SQL (SQLAlchemy) object :param query: The query SQL (SQLAlchemy) object
:return: A Flask Response :return: A Flask Response
:raises: SupersetTimeoutException
""" """
try: try:
timeout = config["SQLLAB_TIMEOUT"] timeout = config["SQLLAB_TIMEOUT"]
@ -2177,6 +2178,9 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
ignore_nan=True, ignore_nan=True,
encoding=None, encoding=None,
) )
except SupersetTimeoutException as ex:
# re-raise exception for api exception handler
raise ex
except Exception as ex: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
logger.exception("Query %i: %s", query.id, str(ex)) logger.exception("Query %i: %s", query.id, str(ex))
return json_error_response(utils.error_msg_from_exception(ex)) return json_error_response(utils.error_msg_from_exception(ex))
@ -2185,6 +2189,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
return json_success(payload) return json_success(payload)
@has_access_api @has_access_api
@handle_api_exception
@expose("/sql_json/", methods=["POST"]) @expose("/sql_json/", methods=["POST"])
@event_logger.log_this @event_logger.log_this
def sql_json(self) -> FlaskResponse: def sql_json(self) -> FlaskResponse:

View File

@ -315,10 +315,13 @@ class TestCore(SupersetTestCase):
def test_slice_data(self): def test_slice_data(self):
# slice data should have some required attributes # slice data should have some required attributes
self.login(username="admin") self.login(username="admin")
slc = self.get_slice("Girls", db.session) slc = self.get_slice(
slice_name="Girls", session=db.session, expunge_from_session=False
)
slc_data_attributes = slc.data.keys() slc_data_attributes = slc.data.keys()
assert "changed_on" in slc_data_attributes assert "changed_on" in slc_data_attributes
assert "modified" in slc_data_attributes assert "modified" in slc_data_attributes
assert "owners" in slc_data_attributes
def test_slices(self): def test_slices(self):
# Testing by hitting the two supported end points for all slices # Testing by hitting the two supported end points for all slices