diff --git a/superset-frontend/spec/javascripts/views/CRUD/alert/AlertList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/alert/AlertList_spec.jsx index 9b9327ded5..dff305e867 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/alert/AlertList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/alert/AlertList_spec.jsx @@ -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 }); diff --git a/superset-frontend/spec/javascripts/views/CRUD/alert/ExecutionLog_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/alert/ExecutionLog_spec.jsx new file mode 100644 index 0000000000..f8f9b213c3 --- /dev/null +++ b/superset-frontend/spec/javascripts/views/CRUD/alert/ExecutionLog_spec.jsx @@ -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( + + + , + ); + 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)"`, + ); + }); +}); diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 57f416be31..ed9954087c 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -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 = () => ( + + + + + + + + + + diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/views/CRUD/alert/AlertList.tsx index cc15595a69..bed3742b01 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertList.tsx @@ -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( '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 ( - - - - ); - }, + }: any) => , accessor: 'last_state', size: 'xs', disableSortBy: true, @@ -176,7 +127,7 @@ function AlertList({ }, }: any) => recipients.map((r: any) => ( - + )), 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: ( <> - {title} + {t(`${title}`)} ), buttonStyle: 'primary', @@ -276,7 +231,7 @@ function AlertList({ const EmptyStateButton = ( ); @@ -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 }, ], }, { diff --git a/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx b/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx new file mode 100644 index 0000000000..d5c950beff --- /dev/null +++ b/superset-frontend/src/views/CRUD/alert/ExecutionLog.tsx @@ -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( + `report/${alertId}/log`, + t('log'), + addDangerToast, + false, + ); + const { + state: { loading: alertLoading, resource: alertResource }, + fetchResource, + } = useSingleViewResource( + '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) => , + 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 ( + <> + + + {t(`${alertResource?.type}`)} {alertResource?.name} + + + Back to all + + + } + /> + + className="execution-log-list-view" + columns={columns} + count={logCount} + data={logs} + fetchData={fetchData} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + /> + + ); +} + +export default withToasts(ExecutionLog); diff --git a/superset-frontend/src/views/CRUD/alert/components/AlertStatusIcon.tsx b/superset-frontend/src/views/CRUD/alert/components/AlertStatusIcon.tsx new file mode 100644 index 0000000000..cb6d3a08fd --- /dev/null +++ b/superset-frontend/src/views/CRUD/alert/components/AlertStatusIcon.tsx @@ -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 ( + + + + ); +} diff --git a/superset-frontend/src/views/CRUD/alert/components/RecipientIcon.tsx b/superset-frontend/src/views/CRUD/alert/components/RecipientIcon.tsx new file mode 100644 index 0000000000..d437488e06 --- /dev/null +++ b/superset-frontend/src/views/CRUD/alert/components/RecipientIcon.tsx @@ -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 ? ( + + + + ) : null; +} diff --git a/superset-frontend/src/views/CRUD/alert/types.ts b/superset-frontend/src/views/CRUD/alert/types.ts index 311ff9ca8a..ca21fa4907 100644 --- a/superset-frontend/src/views/CRUD/alert/types.ts +++ b/superset-frontend/src/views/CRUD/alert/types.ts @@ -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; 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', +} diff --git a/superset/reports/logs/api.py b/superset/reports/logs/api.py index 0b026c45a8..4c175b6635 100644 --- a/superset/reports/logs/api.py +++ b/superset/reports/logs/api.py @@ -50,6 +50,7 @@ class ReportExecutionLogRestApi(BaseSupersetModelRestApi): ] list_columns = [ "id", + "scheduled_dttm", "end_dttm", "start_dttm", "value", diff --git a/superset/views/alerts.py b/superset/views/alerts.py index fe894977ff..eca045fd21 100644 --- a/superset/views/alerts.py +++ b/superset/views/alerts.py @@ -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("//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("//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)