From db635ba97b48b2be9c05fd190b13cc5a9b74867a Mon Sep 17 00:00:00 2001 From: sylenien Date: Thu, 18 Aug 2022 14:41:13 +0200 Subject: [PATCH] feat(ui): redesign alerts creation page --- .../Alerts/AlertForm/BottomButtons.tsx | 44 ++ .../components/Alerts/AlertForm/Condition.tsx | 136 +++++ .../Alerts/AlertForm/NotifyHooks.tsx | 99 ++++ .../components/Alerts/AlertListItem.tsx | 58 +- .../components/Alerts/AlertsList.tsx | 15 +- .../components/Alerts/AlertsView.tsx | 47 +- .../Dashboard/components/Alerts/NewAlert.tsx | 557 ++++++++---------- .../DashboardView/DashboardView.tsx | 2 +- .../components/MetricsView/MetricsView.tsx | 4 +- 9 files changed, 581 insertions(+), 381 deletions(-) create mode 100644 frontend/app/components/Dashboard/components/Alerts/AlertForm/BottomButtons.tsx create mode 100644 frontend/app/components/Dashboard/components/Alerts/AlertForm/Condition.tsx create mode 100644 frontend/app/components/Dashboard/components/Alerts/AlertForm/NotifyHooks.tsx diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertForm/BottomButtons.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertForm/BottomButtons.tsx new file mode 100644 index 000000000..9a5e716f0 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertForm/BottomButtons.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { Button, Icon } from 'UI' + +interface IBottomButtons { + loading: boolean + deleting: boolean + instance: Alert + onDelete: (instance: Alert) => void +} + +function BottomButtons({ loading, instance, deleting, onDelete }: IBottomButtons) { + return ( + <> +
+ +
+
+ {instance.exists() && ( + + )} +
+ + ) +} + +export default BottomButtons diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertForm/Condition.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertForm/Condition.tsx new file mode 100644 index 000000000..a83ceb533 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertForm/Condition.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { Input } from 'UI'; +import Select from 'Shared/Select'; +import { alertConditions as conditions } from 'App/constants'; + +const thresholdOptions = [ + { label: '15 minutes', value: 15 }, + { label: '30 minutes', value: 30 }, + { label: '1 hour', value: 60 }, + { label: '2 hours', value: 120 }, + { label: '4 hours', value: 240 }, + { label: '1 day', value: 1440 }, +]; + +const changeOptions = [ + { label: 'change', value: 'change' }, + { label: '% change', value: 'percent' }, +]; + +interface ICondition { + isThreshold: boolean; + writeOption: (e: any, data: any) => void; + instance: Alert; + triggerOptions: any[]; + writeQuery: (data: any) => void; + writeQueryOption: (e: any, data: any) => void; + unit: any; +} + +function Condition({ + isThreshold, + writeOption, + instance, + triggerOptions, + writeQueryOption, + writeQuery, + unit, +}: ICondition) { + return ( +
+ {!isThreshold && ( +
+ + i.value === instance.query.left)} + onChange={({ value }) => writeQueryOption(null, { name: 'left', value: value.value })} + /> +
+ +
+ +
+ + {'test'} + + )} + {!unit && ( + + )} +
+
+ +
+ + writeOption(null, { name: 'previousPeriod', value })} + /> +
+ )} +
+ ); +} + +export default Condition; diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertForm/NotifyHooks.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertForm/NotifyHooks.tsx new file mode 100644 index 000000000..bb8143473 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertForm/NotifyHooks.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Checkbox } from 'UI'; +import DropdownChips from '../DropdownChips'; + +interface INotifyHooks { + instance: Alert; + onChangeCheck: (e: React.ChangeEvent) => void; + slackChannels: Array; + validateEmail: (value: string) => boolean; + edit: (data: any) => void; + hooks: Array; +} + +function NotifyHooks({ + instance, + onChangeCheck, + slackChannels, + validateEmail, + hooks, + edit, +}: INotifyHooks) { + return ( +
+
+ + + +
+ + {instance.slack && ( +
+ +
+ edit({ slackInput: selected })} + /> +
+
+ )} + + {instance.email && ( +
+ +
+ edit({ emailInput: selected })} + /> +
+
+ )} + + {instance.webhook && ( +
+ + edit({ webhookInput: selected })} + /> +
+ )} +
+ ); +} + +export default NotifyHooks; diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertListItem.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertListItem.tsx index 3bfa860f1..b621921f2 100644 --- a/frontend/app/components/Dashboard/components/Alerts/AlertListItem.tsx +++ b/frontend/app/components/Dashboard/components/Alerts/AlertListItem.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { Icon } from 'UI'; import { checkForRecent } from 'App/date'; -import { withSiteId, alertCreate } from 'App/routes'; +import { withSiteId, alertEdit } from 'App/routes'; // @ts-ignore import { DateTime } from 'luxon'; import { withRouter, RouteComponentProps } from 'react-router-dom'; +import cn from 'classnames'; const getThreshold = (threshold: number) => { if (threshold === 15) return '15 Minutes'; @@ -15,11 +16,31 @@ const getThreshold = (threshold: number) => { if (threshold === 1440) return '1 Day'; }; -const getNotifyChannel = (alert: Record) => { +const getNotifyChannel = (alert: Record, webhooks: Array) => { + const getSlackChannels = () => { + return ( + ' (' + + alert.slackInput + .map((channelId: number) => { + return ( + '#' + + webhooks.find((hook) => hook.webhookId === channelId && hook.type === 'slack').name + ); + }) + .join(', ') + + ')' + ); + }; let str = ''; - if (alert.slack) str = 'Slack'; - if (alert.email) str += (str === '' ? '' : ' and ') + 'Email'; - if (alert.webhool) str += (str === '' ? '' : ' and ') + 'Webhook'; + if (alert.slack) { + str = 'Slack'; + str += alert.slackInput.length > 0 ? getSlackChannels() : ''; + } + if (alert.email) { + str += (str === '' ? '' : ' and ') + (alert.emailInput.length > 1 ? 'Emails' : 'Email'); + str += alert.emailInput.length > 0 ? ' (' + alert.emailInput.join(', ') + ')' : ''; + } + if (alert.webhook) str += (str === '' ? '' : ' and ') + 'Webhook'; if (str === '') return 'OpenReplay'; return str; @@ -29,18 +50,25 @@ interface Props extends RouteComponentProps { alert: Alert; siteId: string; init: (alert?: Alert) => void; + demo?: boolean; + webhooks: Array; } function AlertListItem(props: Props) { - const { alert, siteId, history, init } = props; + const { alert, siteId, history, init, demo, webhooks } = props; const onItemClick = () => { - const path = withSiteId(alertCreate(), siteId); - init(alert) + if (demo) return; + const path = withSiteId(alertEdit(alert.alertId), siteId); + init(alert); history.push(path); }; + return ( -
+
@@ -56,7 +84,12 @@ function AlertListItem(props: Props) {
- {checkForRecent(DateTime.fromMillis(alert.createdAt), 'LLL dd, yyyy, hh:mm a')} + {demo + ? DateTime.fromMillis(+new Date()).toFormat('LLL dd, yyyy, hh:mm a') + : checkForRecent( + DateTime.fromMillis(alert.createdAt || +new Date()), + 'LLL dd, yyyy, hh:mm a' + )}
@@ -66,7 +99,8 @@ function AlertListItem(props: Props) { {alert.query.left} {' is '} - {alert.query.operator}{alert.query.right} {alert.metric.unit} + {alert.query.operator} + {alert.query.right} {alert.metric.unit} {' over the past '} {getThreshold(alert.currentPeriod)} @@ -77,7 +111,7 @@ function AlertListItem(props: Props) { ) : null} {', notify me on '} - {getNotifyChannel(alert)}. + {getNotifyChannel(alert, webhooks)}.
{alert.description ? (
{alert.description}
diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx index ce14773c6..2e544399e 100644 --- a/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx +++ b/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx @@ -4,6 +4,7 @@ import { filterList } from 'App/utils'; import { sliceListPerPage } from 'App/utils'; import { fetchList } from 'Duck/alerts'; import { connect } from 'react-redux'; +import { fetchList as fetchWebhooks } from 'Duck/webhook'; import AlertListItem from './AlertListItem' @@ -14,13 +15,13 @@ interface Props { list: any; alertsSearch: any; siteId: string; - onDelete: (instance: Alert) => void; - onSave: (instance: Alert) => void; + webhooks: Array; init: (instance?: Alert) => void + fetchWebhooks: () => void; } -function AlertsList({ fetchList, list: alertsList, alertsSearch, siteId, init }: Props) { - React.useEffect(() => { fetchList() }, []); +function AlertsList({ fetchList, list: alertsList, alertsSearch, siteId, init, fetchWebhooks, webhooks }: Props) { + React.useEffect(() => { fetchList(); fetchWebhooks() }, []); const alertsArray = alertsList.toJS(); const [page, setPage] = React.useState(1); @@ -50,7 +51,7 @@ function AlertsList({ fetchList, list: alertsList, alertsSearch, siteId, init }: {sliceListPerPage(list, page - 1, pageSize).map((alert: any) => ( - + ))}
@@ -78,6 +79,8 @@ export default connect( list: state.getIn(['alerts', 'list']).sort((a, b) => b.createdAt - a.createdAt), // @ts-ignore alertsSearch: state.getIn(['alerts', 'alertsSearch']), + // @ts-ignore + webhooks: state.getIn(['webhooks', 'list']), }), - { fetchList } + { fetchList, fetchWebhooks } )(AlertsList); diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertsView.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertsView.tsx index e20e5719e..65c3f2ef8 100644 --- a/frontend/app/components/Dashboard/components/Alerts/AlertsView.tsx +++ b/frontend/app/components/Dashboard/components/Alerts/AlertsView.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { Button, PageTitle, Icon } from 'UI'; +import { Button, PageTitle, Icon, Link } from 'UI'; import withPageTitle from 'HOCs/withPageTitle'; import { connect } from 'react-redux'; -import { init, edit, save, remove } from 'Duck/alerts'; -import { confirm } from 'UI'; -import { toast } from 'react-toastify'; +import { init } from 'Duck/alerts'; +import { withSiteId, alertCreate } from 'App/routes'; import AlertsList from './AlertsList'; import AlertsSearch from './AlertsSearch'; @@ -12,58 +11,30 @@ import AlertsSearch from './AlertsSearch'; interface IAlertsView { siteId: string; init: (instance?: Alert) => any; - save: (instance: Alert) => Promise; - remove: (alertId: string) => Promise; } -function AlertsView({ siteId, remove, save, init }: IAlertsView) { - - const onDelete = async (instance: Alert) => { - if ( - await confirm({ - header: 'Confirm', - confirmButton: 'Yes, delete', - confirmation: `Are you sure you want to permanently delete this alert?`, - }) - ) { - remove(instance.alertId).then(() => { - // toggleForm(null, false); - }); - } - }; - const onSave = (instance: Alert) => { - const wasUpdating = instance.exists(); - save(instance).then(() => { - if (!wasUpdating) { - toast.success('New alert saved'); - // toggleForm(null, false); - } else { - toast.success('Alert updated'); - } - }); - }; - +function AlertsView({ siteId, init }: IAlertsView) { return (
- +
- +
- A dashboard is a custom visualization using your OpenReplay data. + Alerts helps your team stay up to date with the activity on your app.
- +
); } // @ts-ignore -const Container = connect(null, { init, edit, save, remove })(AlertsView); +const Container = connect(null, { init })(AlertsView); export default withPageTitle('Alerts - OpenReplay')(Container); diff --git a/frontend/app/components/Dashboard/components/Alerts/NewAlert.tsx b/frontend/app/components/Dashboard/components/Alerts/NewAlert.tsx index 9df9ec85e..72143cf90 100644 --- a/frontend/app/components/Dashboard/components/Alerts/NewAlert.tsx +++ b/frontend/app/components/Dashboard/components/Alerts/NewAlert.tsx @@ -1,92 +1,159 @@ import React, { useEffect } from 'react'; -import { Button, Form, Input, SegmentSelection, Checkbox, Icon } from 'UI'; -import { alertConditions as conditions } from 'App/constants'; +import { Form, SegmentSelection, Icon } from 'UI'; import { connect } from 'react-redux'; -// @ts-ignore -import stl from './alertForm.module.css'; -import DropdownChips from './DropdownChips'; import { validateEmail } from 'App/validate'; +import { fetchTriggerOptions, init, edit, save, remove, fetchList } from 'Duck/alerts'; +import { confirm } from 'UI'; +import { toast } from 'react-toastify'; +import { SLACK, WEBHOOK } from 'App/constants/schedule'; +import { fetchList as fetchWebhooks } from 'Duck/webhook'; +import Breadcrumb from 'Shared/Breadcrumb'; +import { withSiteId, alerts } from 'App/routes'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + import cn from 'classnames'; -import { fetchTriggerOptions } from 'Duck/alerts'; -import Select from 'Shared/Select'; +import WidgetName from '../WidgetName'; +import BottomButtons from './AlertForm/BottomButtons'; +import NotifyHooks from './AlertForm/NotifyHooks'; +import AlertListItem from './AlertListItem'; +import Condition from './AlertForm/Condition'; -const thresholdOptions = [ - { label: '15 minutes', value: 15 }, - { label: '30 minutes', value: 30 }, - { label: '1 hour', value: 60 }, - { label: '2 hours', value: 120 }, - { label: '4 hours', value: 240 }, - { label: '1 day', value: 1440 }, -]; -const changeOptions = [ - { label: 'change', value: 'change' }, - { label: '% change', value: 'percent' }, -]; - -const Circle = ({ text }: { text: string}) => ( -
+const Circle = ({ text }: { text: string }) => ( +
{text}
); interface ISection { - index: string - title: string - description?: string - content: React.ReactNode + index: string; + title: string; + description?: string; + content: React.ReactNode; } const Section = ({ index, title, description, content }: ISection) => ( -
-
+
+
-
+
{title} {description &&
{description}
}
-
{content}
+
{content}
); -interface IProps { - instance: Alert - style: Record - slackChannels: any[] - webhooks: any[] - loading: boolean - deleting: boolean - triggerOptions: any[] - onDelete: (instance: Alert) => void - onClose: () => void - fetchTriggerOptions: () => void - edit: (query: any) => void - onSubmit: (instance: Alert) => void +interface IProps extends RouteComponentProps { + siteId: string; + instance: Alert; + slackChannels: any[]; + webhooks: any[]; + loading: boolean; + deleting: boolean; + triggerOptions: any[]; + list: any, + fetchTriggerOptions: () => void; + edit: (query: any) => void; + init: (alert?: Alert) => any; + save: (alert: Alert) => Promise; + remove: (alertId: string) => Promise; + onSubmit: (instance: Alert) => void; + fetchWebhooks: () => void; + fetchList: () => void; } const NewAlert = (props: IProps) => { const { instance, - slackChannels, + siteId, webhooks, loading, - onDelete, deleting, triggerOptions, - style, + init, + edit, + save, + remove, + fetchWebhooks, + fetchList, + list, } = props; - const write = ({ target: { value, name } }: React.ChangeEvent) => props.edit({ [name]: value }); - const writeOption = (_: React.ChangeEvent, { name, value }: { name: string, value: Record}) => props.edit({ [name]: value.value }); - const onChangeCheck = ({ target: { checked, name } }: React.ChangeEvent) => props.edit({ [name]: checked }); + const [expanded, setExpanded] = React.useState(false); useEffect(() => { + if (list.size === 0) fetchList(); props.fetchTriggerOptions(); + fetchWebhooks(); }, []); - const writeQueryOption = (e: React.ChangeEvent, { name, value }: { name: string, value: string }) => { + useEffect(() => { + if (list.size > 0) { + const alertId = location.pathname.split('/').pop() + const currentAlert = list.toJS().find((alert: Alert) => alert.alertId === parseInt(alertId, 10)); + init(currentAlert); + } + }, [list]) + + + const write = ({ target: { value, name } }: React.ChangeEvent) => + props.edit({ [name]: value }); + const writeOption = ( + _: React.ChangeEvent, + { name, value }: { name: string; value: Record } + ) => props.edit({ [name]: value.value }); + const onChangeCheck = ({ target: { checked, name } }: React.ChangeEvent) => + props.edit({ [name]: checked }); + + const onDelete = async (instance: Alert) => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to permanently delete this alert?`, + }) + ) { + remove(instance.alertId).then(() => { + props.history.push(withSiteId(alerts(), siteId)) + }); + } + }; + const onSave = (instance: Alert) => { + const wasUpdating = instance.exists(); + save(instance).then(() => { + if (!wasUpdating) { + toast.success('New alert saved'); + props.history.push(withSiteId(alerts(), siteId)) + } else { + toast.success('Alert updated'); + } + }); + }; + + const onClose = () => { + props.history.push(withSiteId(alerts(), siteId)) + } + + const slackChannels = webhooks + .filter((hook) => hook.type === SLACK) + .map(({ webhookId, name }) => ({ value: webhookId, label: name })) + // @ts-ignore + .toJS(); + const hooks = webhooks + .filter((hook) => hook.type === WEBHOOK) + .map(({ webhookId, name }) => ({ value: webhookId, label: name })) + // @ts-ignore + .toJS(); + + + + const writeQueryOption = ( + e: React.ChangeEvent, + { name, value }: { name: string; value: string } + ) => { const { query } = instance; props.edit({ query: { ...query, [name]: value } }); }; @@ -104,291 +171,137 @@ const NewAlert = (props: IProps) => { const isThreshold = instance.detectionMethod === 'threshold'; return ( -
props.onSubmit(instance)} - id="alert-form" - > -
- -
-
- props.edit({ [name]: value })} - value={{ value: instance.detectionMethod }} - list={[ - { name: 'Threshold', value: 'threshold' }, - { name: 'Change', value: 'change' }, - ]} - /> -
- {isThreshold && - 'Eg. Alert me if memory.avg is greater than 500mb over the past 4 hours.'} - {!isThreshold && - 'Eg. Alert me if % change of memory.avg is greater than 10% over the past 4 hours compared to the previous 4 hours.'} -
-
-
- } - /> - -
- -
- {!isThreshold && ( -
- - i.value === instance.query.left)} - // onChange={ writeQueryOption } - onChange={({ value }) => - writeQueryOption(null, { name: 'left', value: value.value }) - } - /> -
- -
- -
- - {'test'} - - )} - {!unit && ( - - )} -
-
- -
- - writeOption(null, { name: 'previousPeriod', value })} - /> -
- )} -
- } - /> - -
- -
-
- - - -
- - {instance.slack && ( -
- -
- props.edit({ slackInput: selected })} - /> -
-
- )} - - {instance.email && ( -
- -
- props.edit({ emailInput: selected })} - /> -
-
- )} - - {instance.webhook && ( -
- - props.edit({ webhookInput: selected })} - /> -
- )} -
- } - /> -
- -
-
- -
- -
-
- {instance.exists() && ( - - )} +
+ {expanded ? 'Close' : 'Edit'} + +
+
+ {expanded ? ( + <> +
+
+ props.edit({ [name]: value })} + value={{ value: instance.detectionMethod }} + list={[ + { name: 'Threshold', value: 'threshold' }, + { name: 'Change', value: 'change' }, + ]} + /> +
+ {isThreshold && + 'Eg. When Threshold is above 1ms over the past 15mins, notify me through Slack #foss-notifications.'} + {!isThreshold && + 'Eg. Alert me if % change of memory.avg is greater than 10% over the past 4 hours compared to the previous 4 hours.'} +
+
+
+ } + /> +
+ } + /> +
+ } + /> +
+ +
+ +
+ + ) : null} + + +
+ {instance && ( + null} webhooks={webhooks} /> + )}
- + ); }; -export default connect( +export default withRouter(connect( (state) => ({ // @ts-ignore instance: state.getIn(['alerts', 'instance']), + //@ts-ignore + list: state.getIn(['alerts', 'list']), // @ts-ignore triggerOptions: state.getIn(['alerts', 'triggerOptions']), // @ts-ignore loading: state.getIn(['alerts', 'saveRequest', 'loading']), // @ts-ignore deleting: state.getIn(['alerts', 'removeRequest', 'loading']), + // @ts-ignore + webhooks: state.getIn(['webhooks', 'list']), }), - { fetchTriggerOptions } -)(NewAlert); + { fetchTriggerOptions, init, edit, save, remove, fetchWebhooks, fetchList } + // @ts-ignore +)(NewAlert)); diff --git a/frontend/app/components/Dashboard/components/DashboardView/DashboardView.tsx b/frontend/app/components/Dashboard/components/DashboardView/DashboardView.tsx index df8e198d8..470a43cb0 100644 --- a/frontend/app/components/Dashboard/components/DashboardView/DashboardView.tsx +++ b/frontend/app/components/Dashboard/components/DashboardView/DashboardView.tsx @@ -180,5 +180,5 @@ function DashboardView(props: Props) { ); } - +// @ts-ignore export default withPageTitle('Dashboards - OpenReplay')(withReport(withRouter(withModal(observer(DashboardView))))); diff --git a/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx b/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx index 85cefe70d..47512629f 100644 --- a/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx +++ b/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx @@ -6,8 +6,8 @@ import MetricsSearch from '../MetricsSearch'; import { useStore } from 'App/mstore'; import { useObserver } from 'mobx-react-lite'; -interface Props{ - siteId: number; +interface Props { + siteId: string; } function MetricsView({ siteId }: Props) { const { metricStore } = useStore();