feat: alert/report execution log list view (#11937)

This commit is contained in:
Lily Kuang 2020-12-10 15:15:13 -08:00 committed by GitHub
parent 475f59cb1c
commit df6efb6aa2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 483 additions and 82 deletions

View File

@ -16,17 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { styledMount as mount } from 'spec/helpers/theming';
import AlertList from 'src/views/CRUD/alert/AlertList';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { Switch } from 'src/common/components/Switch';
import ListView from 'src/components/ListView';
import SubMenu from 'src/components/Menu/SubMenu';
import { Switch } from 'src/common/components/Switch';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import AlertList from 'src/views/CRUD/alert/AlertList';
// store needed for withToasts(AlertList)
const mockStore = configureStore([thunk]);
@ -35,6 +34,7 @@ const store = mockStore({});
const alertsEndpoint = 'glob:*/api/v1/report/?*';
const alertEndpoint = 'glob:*/api/v1/report/*';
const alertsInfoEndpoint = 'glob:*/api/v1/report/_info*';
const alertsCreatedByEndpoint = 'glob:*/api/v1/report/related/created_by*';
const mockalerts = [...new Array(3)].map((_, i) => ({
active: true,
@ -74,6 +74,7 @@ fetchMock.get(alertsEndpoint, {
fetchMock.get(alertsInfoEndpoint, {
permissions: ['can_delete', 'can_edit'],
});
fetchMock.get(alertsCreatedByEndpoint, { result: [] });
fetchMock.put(alertEndpoint, { ...mockalerts[0], active: false });
fetchMock.put(alertsEndpoint, { ...mockalerts[0], active: false });

View File

@ -0,0 +1,105 @@
/**
* 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 fetchMock from 'fetch-mock';
import React from 'react';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { styledMount as mount } from 'spec/helpers/theming';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import ListView from 'src/components/ListView';
import ExecutionLog from 'src/views/CRUD/alert/ExecutionLog';
// store needed for withToasts(ExecutionLog)
const mockStore = configureStore([thunk]);
const store = mockStore({});
const executionLogsEndpoint = 'glob:*/api/v1/report/*/log*';
const reportEndpoint = 'glob:*/api/v1/report/*';
fetchMock.delete(executionLogsEndpoint, {});
const mockannotations = [...new Array(3)].map((_, i) => ({
end_dttm: new Date().toISOString,
error_message: `report ${i} error message`,
id: i,
scheduled_dttm: new Date().toISOString,
start_dttm: new Date().toISOString,
state: 'Success',
value: `report ${i} value`,
}));
fetchMock.get(executionLogsEndpoint, {
ids: [2, 0, 1],
result: mockannotations,
count: 3,
});
fetchMock.get(reportEndpoint, {
id: 1,
result: { name: 'Test 0' },
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useParams: () => ({ alertId: '1' }),
}));
async function mountAndWait(props) {
const mounted = mount(
<Provider store={store}>
<ExecutionLog {...props} />
</Provider>,
);
await waitForComponentToPaint(mounted);
return mounted;
}
describe('ExecutionLog', () => {
let wrapper;
beforeAll(async () => {
wrapper = await mountAndWait();
});
it('renders', () => {
expect(wrapper.find(ExecutionLog)).toExist();
});
it('renders a ListView', () => {
expect(wrapper.find(ListView)).toExist();
});
it('fetches report/alert', () => {
const callsQ = fetchMock.calls(/report\/1/);
expect(callsQ).toHaveLength(2);
expect(callsQ[1][0]).toMatchInlineSnapshot(
`"http://localhost/api/v1/report/1"`,
);
});
it('fetches execution logs', () => {
const callsQ = fetchMock.calls(/report\/1\/log/);
expect(callsQ).toHaveLength(1);
expect(callsQ[0][0]).toMatchInlineSnapshot(
`"http://localhost/api/v1/report/1/log/?q=(order_column:start_dttm,order_direction:desc,page:0,page_size:25)"`,
);
});
});

View File

@ -29,6 +29,7 @@ import ErrorBoundary from 'src/components/ErrorBoundary';
import Menu from 'src/components/Menu/Menu';
import FlashProvider from 'src/components/FlashProvider';
import AlertList from 'src/views/CRUD/alert/AlertList';
import ExecutionLog from 'src/views/CRUD/alert/ExecutionLog';
import AnnotationLayersList from 'src/views/CRUD/annotationlayers/AnnotationLayersList';
import AnnotationList from 'src/views/CRUD/annotation/AnnotationList';
import ChartList from 'src/views/CRUD/chart/ChartList';
@ -135,6 +136,16 @@ const App = () => (
<AlertList user={user} isReportEnabled />
</ErrorBoundary>
</Route>
<Route path="/alert/:alertId/log">
<ErrorBoundary>
<ExecutionLog user={user} />
</ErrorBoundary>
</Route>
<Route path="/report/:alertId/log">
<ErrorBoundary>
<ExecutionLog user={user} isReportEnabled />
</ErrorBoundary>
</Route>
</Switch>
<ToastPresenter />
</QueryParamProvider>

View File

@ -17,25 +17,25 @@
* under the License.
*/
import React, { useMemo, useEffect } from 'react';
import { t, styled } from '@superset-ui/core';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import Button from 'src/components/Button';
import Icon, { IconName } from 'src/components/Icon';
import { Tooltip } from 'src/common/components/Tooltip';
import { t } from '@superset-ui/core';
import React, { useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { Switch } from 'src/common/components/Switch';
import Button from 'src/components/Button';
import FacePile from 'src/components/FacePile';
import ListView, { Filters, FilterOperators } from 'src/components/ListView';
import { IconName } from 'src/components/Icon';
import ListView, { FilterOperators, Filters } from 'src/components/ListView';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import AlertStatusIcon from 'src/views/CRUD/alert/components/AlertStatusIcon';
import RecipientIcon from 'src/views/CRUD/alert/components/RecipientIcon';
import {
useListViewResource,
useSingleViewResource,
} from 'src/views/CRUD/hooks';
import { AlertObject } from './types';
import { createErrorHandler, createFetchRelated } from 'src/views/CRUD/utils';
import { AlertObject, AlertState } from './types';
const PAGE_SIZE = 25;
@ -48,27 +48,13 @@ interface AlertListProps {
};
}
const StatusIcon = styled(Icon)<{ status: string }>`
color: ${({ status, theme }) => {
switch (status) {
case 'Working':
return theme.colors.alert.base;
case 'Error':
return theme.colors.error.base;
case 'Success':
return theme.colors.success.base;
default:
return theme.colors.grayscale.base;
}
}};
`;
function AlertList({
addDangerToast,
isReportEnabled = false,
user,
}: AlertListProps) {
const title = isReportEnabled ? t('report') : t('alert');
const title = isReportEnabled ? 'report' : 'alert';
const pathName = isReportEnabled ? 'Reports' : 'Alerts';
const initalFilters = useMemo(
() => [
{
@ -92,7 +78,7 @@ function AlertList({
undefined,
initalFilters,
);
const pathName = isReportEnabled ? 'Reports' : 'Alerts';
const { updateResource } = useSingleViewResource<AlertObject>(
'report',
t('reports'),
@ -125,42 +111,7 @@ function AlertList({
row: {
original: { last_state: lastState },
},
}: any) => {
const lastStateConfig = {
name: '',
label: '',
status: '',
};
switch (lastState) {
case 'Success':
lastStateConfig.name = 'check';
lastStateConfig.label = t('Success');
lastStateConfig.status = 'Success';
break;
case 'Working':
lastStateConfig.name = 'exclamation';
lastStateConfig.label = t('Working');
lastStateConfig.status = 'Working';
break;
case 'Error':
lastStateConfig.name = 'x-small';
lastStateConfig.label = t('Error');
lastStateConfig.status = 'Error';
break;
default:
lastStateConfig.name = 'exclamation';
lastStateConfig.label = t('Working');
lastStateConfig.status = 'Working';
}
return (
<Tooltip title={lastStateConfig.label} placement="bottom">
<StatusIcon
name={lastStateConfig.name as IconName}
status={lastStateConfig.status}
/>
</Tooltip>
);
},
}: any) => <AlertStatusIcon state={lastState} />,
accessor: 'last_state',
size: 'xs',
disableSortBy: true,
@ -176,7 +127,7 @@ function AlertList({
},
}: any) =>
recipients.map((r: any) => (
<Icon key={r.id} name={r.type as IconName} />
<RecipientIcon key={r.id} type={r.type} />
)),
accessor: 'recipients',
Header: t('Notification Method'),
@ -217,16 +168,20 @@ function AlertList({
},
{
Cell: ({ row: { original } }: any) => {
const history = useHistory();
const handleEdit = () => {}; // handleAnnotationEdit(original);
const handleDelete = () => {}; // setAlertCurrentlyDeleting(original);
const handleGotoExecutionLog = () =>
history.push(`/${original.type.toLowerCase()}/${original.id}/log`);
const actions = [
canEdit
? {
label: 'preview-action',
label: 'execution-log-action',
tooltip: t('Execution Log'),
placement: 'bottom',
icon: 'note' as IconName,
onClick: handleEdit,
onClick: handleGotoExecutionLog,
}
: null,
canEdit
@ -266,7 +221,7 @@ function AlertList({
subMenuButtons.push({
name: (
<>
<i className="fa fa-plus" /> {title}
<i className="fa fa-plus" /> {t(`${title}`)}
</>
),
buttonStyle: 'primary',
@ -276,7 +231,7 @@ function AlertList({
const EmptyStateButton = (
<Button buttonStyle="primary" onClick={() => {}}>
<i className="fa fa-plus" /> {title}
<i className="fa fa-plus" /> {t(`${title}`)}
</Button>
);
@ -310,9 +265,11 @@ function AlertList({
operator: FilterOperators.equals,
unfilteredLabel: 'Any',
selects: [
{ label: t('Success'), value: 'Success' },
{ label: t('Working'), value: 'Working' },
{ label: t('Error'), value: 'Error' },
{ label: t(`${AlertState.success}`), value: AlertState.success },
{ label: t(`${AlertState.working}`), value: AlertState.working },
{ label: t(`${AlertState.error}`), value: AlertState.error },
{ label: t(`${AlertState.noop}`), value: AlertState.noop },
{ label: t(`${AlertState.grace}`), value: AlertState.grace },
],
},
{

View File

@ -0,0 +1,158 @@
/**
* 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 { styled, t } from '@superset-ui/core';
import moment from 'moment';
import React, { useEffect, useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import ListView from 'src/components/ListView';
import SubMenu from 'src/components/Menu/SubMenu';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import { fDuration } from 'src/modules/dates';
import AlertStatusIcon from 'src/views/CRUD/alert/components/AlertStatusIcon';
import {
useListViewResource,
useSingleViewResource,
} from 'src/views/CRUD/hooks';
import { AlertObject, LogObject } from './types';
const PAGE_SIZE = 25;
const StyledHeader = styled.div`
display: flex;
flex-direction: row;
a,
Link {
margin-left: 16px;
font-size: 12px;
font-weight: normal;
text-decoration: underline;
}
`;
interface ExecutionLogProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
isReportEnabled: boolean;
}
function ExecutionLog({ addDangerToast, isReportEnabled }: ExecutionLogProps) {
const { alertId }: any = useParams();
const {
state: { loading, resourceCount: logCount, resourceCollection: logs },
fetchData,
} = useListViewResource<LogObject>(
`report/${alertId}/log`,
t('log'),
addDangerToast,
false,
);
const {
state: { loading: alertLoading, resource: alertResource },
fetchResource,
} = useSingleViewResource<AlertObject>(
'report',
t('reports'),
addDangerToast,
);
useEffect(() => {
if (alertId !== null && !alertLoading) {
fetchResource(alertId);
}
}, [alertId]);
const initialSort = [{ id: 'start_dttm', desc: true }];
const columns = useMemo(
() => [
{
Cell: ({
row: {
original: { state },
},
}: any) => <AlertStatusIcon state={state} />,
accessor: 'state',
Header: t('State'),
size: 'xs',
disableSortBy: true,
},
{
accessor: 'scheduled_dttm',
Header: t('Scheduled at'),
},
{
Cell: ({
row: {
original: { start_dttm: startDttm },
},
}: any) => moment(new Date(startDttm)).format('ll'),
Header: t('Start At'),
accessor: 'start_dttm',
},
{
Cell: ({
row: {
original: { start_dttm: startDttm, end_dttm: endDttm },
},
}: any) => fDuration(endDttm - startDttm),
Header: t('Duration'),
disableSortBy: true,
},
{
accessor: 'value',
Header: t('Value'),
},
{
accessor: 'error_message',
Header: t('Error Message'),
},
],
[],
);
const path = `/${isReportEnabled ? 'report' : 'alert'}/list/`;
return (
<>
<SubMenu
name={
<StyledHeader>
<span>
{t(`${alertResource?.type}`)} {alertResource?.name}
</span>
<span>
<Link to={path}>Back to all</Link>
</span>
</StyledHeader>
}
/>
<ListView<LogObject>
className="execution-log-list-view"
columns={columns}
count={logCount}
data={logs}
fetchData={fetchData}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
/>
</>
);
}
export default withToasts(ExecutionLog);

View File

@ -0,0 +1,75 @@
/**
* 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 { styled, t } from '@superset-ui/core';
import React from 'react';
import { Tooltip } from 'src/common/components/Tooltip';
import Icon, { IconName } from 'src/components/Icon';
import { AlertState } from '../types';
const StatusIcon = styled(Icon)<{ status: string }>`
color: ${({ status, theme }) => {
switch (status) {
case AlertState.working:
return theme.colors.alert.base;
case AlertState.error:
return theme.colors.error.base;
case AlertState.success:
return theme.colors.success.base;
default:
return theme.colors.grayscale.base;
}
}};
`;
export default function AlertStatusIcon({ state }: { state: string }) {
const lastStateConfig = {
name: '',
label: '',
status: '',
};
switch (state) {
case AlertState.success:
lastStateConfig.name = 'check';
lastStateConfig.label = t(`${AlertState.success}`);
lastStateConfig.status = AlertState.success;
break;
case AlertState.working:
lastStateConfig.name = 'exclamation';
lastStateConfig.label = t(`${AlertState.working}`);
lastStateConfig.status = AlertState.working;
break;
case AlertState.error:
lastStateConfig.name = 'x-small';
lastStateConfig.label = t(`${AlertState.error}`);
lastStateConfig.status = AlertState.error;
break;
default:
lastStateConfig.name = 'exclamation';
lastStateConfig.label = t(`${AlertState.working}`);
lastStateConfig.status = AlertState.working;
}
return (
<Tooltip title={lastStateConfig.label} placement="bottom">
<StatusIcon
name={lastStateConfig.name as IconName}
status={lastStateConfig.status}
/>
</Tooltip>
);
}

View File

@ -0,0 +1,48 @@
/**
* 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 { t } from '@superset-ui/core';
import React from 'react';
import { Tooltip } from 'src/common/components/Tooltip';
import Icon, { IconName } from 'src/components/Icon';
import { RecipientIconName } from '../types';
export default function RecipientIcon({ type }: { type: string }) {
const recipientIconConfig = {
name: '',
label: '',
};
switch (type) {
case RecipientIconName.email:
recipientIconConfig.name = 'email';
recipientIconConfig.label = t(`${RecipientIconName.email}`);
break;
case RecipientIconName.slack:
recipientIconConfig.name = 'slack';
recipientIconConfig.label = t(`${RecipientIconName.slack}`);
break;
default:
recipientIconConfig.name = '';
recipientIconConfig.label = '';
}
return recipientIconConfig.name.length ? (
<Tooltip title={recipientIconConfig.label} placement="bottom">
<Icon name={recipientIconConfig.name as IconName} />
</Tooltip>
) : null;
}

View File

@ -38,9 +38,32 @@ export type AlertObject = {
created_on?: string;
id?: number;
last_eval_dttm?: number;
last_state?: string;
last_state?: 'Success' | 'Working' | 'Error' | 'Not triggered' | 'On Grace';
name?: string;
owners?: Array<Owner>;
recipients?: recipients;
type?: string;
};
export type LogObject = {
end_dttm: string;
error_message: string;
id: number;
scheduled_dttm: string;
start_dttm: string;
state: string;
value: string;
};
export enum AlertState {
success = 'Success',
working = 'Working',
error = 'Error',
noop = 'Not triggered',
grace = 'On Grace',
}
export enum RecipientIconName {
email = 'Email',
slack = 'Slack',
}

View File

@ -50,6 +50,7 @@ class ReportExecutionLogRestApi(BaseSupersetModelRestApi):
]
list_columns = [
"id",
"scheduled_dttm",
"end_dttm",
"start_dttm",
"value",

View File

@ -71,7 +71,7 @@ class AlertObservationModelView(
class AlertReportModelView(SupersetModelView):
datamodel = SQLAInterface(ReportSchedule)
route_base = "/report"
include_route_methods = RouteMethod.CRUD_SET
include_route_methods = RouteMethod.CRUD_SET | {"log"}
@expose("/list/")
@has_access
@ -84,11 +84,22 @@ class AlertReportModelView(SupersetModelView):
return super().render_app_template()
@expose("/<pk>/log/", methods=["GET"])
@has_access
def log(self, pk: int) -> FlaskResponse: # pylint: disable=unused-argument
if not (
is_feature_enabled("ENABLE_REACT_CRUD_VIEWS")
and is_feature_enabled("SIP_34_ALERTS_UI")
):
return super().list()
return super().render_app_template()
class AlertModelView(SupersetModelView): # pylint: disable=too-many-ancestors
datamodel = SQLAInterface(Alert)
route_base = "/alert"
include_route_methods = RouteMethod.CRUD_SET
include_route_methods = RouteMethod.CRUD_SET | {"log"}
list_columns = (
"label",
@ -197,6 +208,17 @@ class AlertModelView(SupersetModelView): # pylint: disable=too-many-ancestors
return super().render_app_template()
@expose("/<pk>/log/", methods=["GET"])
@has_access
def log(self, pk: int) -> FlaskResponse: # pylint: disable=unused-argument
if not (
is_feature_enabled("ENABLE_REACT_CRUD_VIEWS")
and is_feature_enabled("SIP_34_ALERTS_UI")
):
return super().list()
return super().render_app_template()
def pre_add(self, item: "AlertModelView") -> None:
item.recipients = get_email_address_str(item.recipients)