From df3888ba608bef4f45b66f133f72df457206eb47 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 4 Jul 2023 12:07:05 +0200 Subject: [PATCH] Alerting: Refactor Alertmanager picker (#70720) --- .../app/features/alerting/unified/Admin.tsx | 25 ++-- .../features/alerting/unified/AlertGroups.tsx | 45 +++---- .../features/alerting/unified/MuteTimings.tsx | 55 ++++++-- .../alerting/unified/NotificationPolicies.tsx | 62 ++++----- .../features/alerting/unified/Receivers.tsx | 6 +- .../features/alerting/unified/Silences.tsx | 74 +++++------ .../unified/components/AlertManagerPicker.tsx | 28 ++-- .../components/AlertingPageWrapper.tsx | 67 ++++++++-- .../components/NoAlertManagerWarning.tsx | 22 +--- .../admin/AlertmanagerConfig.test.tsx | 5 +- .../components/admin/AlertmanagerConfig.tsx | 38 +++--- .../alert-groups/AlertGroupFilter.tsx | 10 -- .../contact-points/ContactPoints.v1.test.tsx | 59 +++------ .../contact-points/ContactPoints.v1.tsx | 70 +--------- .../mute-timings/MuteTimingForm.tsx | 37 ++---- .../EditNotificationPolicyForm.test.tsx | 15 ++- .../hooks/useAlertManagerSourceName.test.tsx | 103 --------------- .../hooks/useAlertManagerSourceName.ts | 67 ---------- .../unified/hooks/useMuteTimingOptions.ts | 10 +- .../state/AlertmanagerContext.test.tsx | 120 ++++++++++++++++++ .../unified/state/AlertmanagerContext.tsx | 77 +++++++++++ .../panel/alertGroups/AlertmanagerPicker.tsx | 40 ++++++ .../app/plugins/panel/alertGroups/module.tsx | 2 +- 23 files changed, 514 insertions(+), 523 deletions(-) delete mode 100644 public/app/features/alerting/unified/hooks/useAlertManagerSourceName.test.tsx delete mode 100644 public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts create mode 100644 public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx create mode 100644 public/app/features/alerting/unified/state/AlertmanagerContext.tsx create mode 100644 public/app/plugins/panel/alertGroups/AlertmanagerPicker.tsx diff --git a/public/app/features/alerting/unified/Admin.tsx b/public/app/features/alerting/unified/Admin.tsx index 783b5c60cf1..87754cb6c17 100644 --- a/public/app/features/alerting/unified/Admin.tsx +++ b/public/app/features/alerting/unified/Admin.tsx @@ -1,22 +1,27 @@ import React from 'react'; -import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import AlertmanagerConfig from './components/admin/AlertmanagerConfig'; import { ExternalAlertmanagers } from './components/admin/ExternalAlertmanagers'; -import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; +import { useAlertmanager } from './state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; export default function Admin(): JSX.Element { - const alertManagers = useAlertManagersByPermission('notification'); - const [alertManagerSourceName] = useAlertManagerSourceName(alertManagers); - - const isGrafanaAmSelected = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME; - return ( - + + + + ); +} + +function AdminPageContents() { + const { selectedAlertmanager } = useAlertmanager(); + const isGrafanaAmSelected = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; + + return ( + <> {isGrafanaAmSelected && } - + ); } diff --git a/public/app/features/alerting/unified/AlertGroups.tsx b/public/app/features/alerting/unified/AlertGroups.tsx index 920531c6f2d..addf2cbd29a 100644 --- a/public/app/features/alerting/unified/AlertGroups.tsx +++ b/public/app/features/alerting/unified/AlertGroups.tsx @@ -9,15 +9,13 @@ import { useDispatch } from 'app/types'; import { AlertmanagerChoice } from '../../../plugins/datasource/alertmanager/types'; import { alertmanagerApi } from './api/alertmanagerApi'; -import { AlertingPageWrapper } from './components/AlertingPageWrapper'; -import { NoAlertManagerWarning } from './components/NoAlertManagerWarning'; +import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { AlertGroup } from './components/alert-groups/AlertGroup'; import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter'; -import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; import { useFilteredAmGroups } from './hooks/useFilteredAmGroups'; import { useGroupedAlerts } from './hooks/useGroupedAlerts'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { useAlertmanager } from './state/AlertmanagerContext'; import { fetchAlertGroupsAction } from './state/actions'; import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; @@ -27,8 +25,7 @@ import { initialAsyncRequestState } from './utils/redux'; const AlertGroups = () => { const { useGetAlertmanagerChoiceStatusQuery } = alertmanagerApi; - const alertManagers = useAlertManagersByPermission('instance'); - const [alertManagerSourceName] = useAlertManagerSourceName(alertManagers); + const { selectedAlertmanager } = useAlertmanager(); const dispatch = useDispatch(); const [queryParams] = useQueryParams(); const { groupBy = [] } = getFiltersFromUrlParams(queryParams); @@ -37,23 +34,19 @@ const AlertGroups = () => { const { currentData: amConfigStatus } = useGetAlertmanagerChoiceStatusQuery(); const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups); - const { - loading, - error, - result: results = [], - } = alertGroups[alertManagerSourceName || ''] ?? initialAsyncRequestState; + const { loading, error, result: results = [] } = alertGroups[selectedAlertmanager || ''] ?? initialAsyncRequestState; const groupedAlerts = useGroupedAlerts(results, groupBy); const filteredAlertGroups = useFilteredAmGroups(groupedAlerts); const grafanaAmDeliveryDisabled = - alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && + selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME && amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.External; useEffect(() => { function fetchNotifications() { - if (alertManagerSourceName) { - dispatch(fetchAlertGroupsAction(alertManagerSourceName)); + if (selectedAlertmanager) { + dispatch(fetchAlertGroupsAction(selectedAlertmanager)); } } fetchNotifications(); @@ -61,18 +54,10 @@ const AlertGroups = () => { return () => { clearInterval(interval); }; - }, [dispatch, alertManagerSourceName]); - - if (!alertManagerSourceName) { - return ( - - - - ); - } + }, [dispatch, selectedAlertmanager]); return ( - + <> {loading && } {error && !loading && ( @@ -96,19 +81,25 @@ const AlertGroups = () => { (index === 0 && Object.keys(group.labels).length > 0)) && (

Grouped by: {Object.keys(group.labels).join(', ')}

)} - + ); })} {results && !filteredAlertGroups.length &&

No results.

} -
+ ); }; +const AlertGroupsPage = () => ( + + + +); + const getStyles = (theme: GrafanaTheme2) => ({ groupingBanner: css` margin: ${theme.spacing(2, 0)}; `, }); -export default AlertGroups; +export default AlertGroupsPage; diff --git a/public/app/features/alerting/unified/MuteTimings.tsx b/public/app/features/alerting/unified/MuteTimings.tsx index 78d9aa558a2..f5333afcf25 100644 --- a/public/app/features/alerting/unified/MuteTimings.tsx +++ b/public/app/features/alerting/unified/MuteTimings.tsx @@ -1,38 +1,38 @@ -import React, { useCallback, useEffect } from 'react'; -import { Route, Redirect, Switch } from 'react-router-dom'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Route, Redirect, Switch, useRouteMatch } from 'react-router-dom'; +import { NavModelItem } from '@grafana/data'; import { Alert } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; +import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import MuteTimingForm from './components/mute-timings/MuteTimingForm'; -import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { useAlertmanager } from './state/AlertmanagerContext'; import { fetchAlertManagerConfigAction } from './state/actions'; import { initialAsyncRequestState } from './utils/redux'; const MuteTimings = () => { const [queryParams] = useQueryParams(); const dispatch = useDispatch(); - const alertManagers = useAlertManagersByPermission('notification'); - const [alertManagerSourceName] = useAlertManagerSourceName(alertManagers); + const { selectedAlertmanager } = useAlertmanager(); const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); const fetchConfig = useCallback(() => { - if (alertManagerSourceName) { - dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); + if (selectedAlertmanager) { + dispatch(fetchAlertManagerConfigAction(selectedAlertmanager)); } - }, [alertManagerSourceName, dispatch]); + }, [selectedAlertmanager, dispatch]); useEffect(() => { fetchConfig(); }, [fetchConfig]); const { result, error, loading } = - (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + (selectedAlertmanager && amConfigs[selectedAlertmanager]) || initialAsyncRequestState; const config = result?.alertmanager_config; @@ -57,7 +57,7 @@ const MuteTimings = () => { return ( <> {error && !loading && !result && ( - + {error.message || 'Unknown error.'} )} @@ -90,4 +90,35 @@ const MuteTimings = () => { ); }; -export default MuteTimings; +const MuteTimingsPage = () => { + const pageNav = useMuteTimingNavData(); + + return ( + + + + ); +}; + +export function useMuteTimingNavData() { + const { isExact, path } = useRouteMatch(); + const [pageNav, setPageNav] = useState | undefined>(); + + useEffect(() => { + if (path === '/alerting/routes/mute-timing/new') { + setPageNav({ + id: 'alert-policy-new', + text: 'Add mute timing', + }); + } else if (path === '/alerting/routes/mute-timing/edit') { + setPageNav({ + id: 'alert-policy-edit', + text: 'Edit mute timing', + }); + } + }, [path, isExact]); + + return pageNav; +} + +export default MuteTimingsPage; diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index 36087bc1e97..fce3f746176 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -14,10 +14,8 @@ import { useCleanup } from '../../../core/hooks/useCleanup'; import { alertmanagerApi } from './api/alertmanagerApi'; import { useGetContactPointsState } from './api/receiversApi'; -import { AlertManagerPicker } from './components/AlertManagerPicker'; -import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; -import { NoAlertManagerWarning } from './components/NoAlertManagerWarning'; import { ProvisionedResource, ProvisioningAlert } from './components/Provisioning'; import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable'; import { @@ -32,9 +30,8 @@ import { useAlertGroupsModal, } from './components/notification-policies/Modals'; import { Policy } from './components/notification-policies/Policy'; -import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; import { useAlertmanagerConfig } from './hooks/useAlertmanagerConfig'; +import { useAlertmanager } from './state/AlertmanagerContext'; import { updateAlertManagerConfigAction } from './state/actions'; import { FormAmRoute } from './types/amroutes'; import { useRouteGroupsMatcher } from './useRouteGroupsMatcher'; @@ -64,17 +61,15 @@ const AmRoutes = () => { const [labelMatchersFilter, setLabelMatchersFilter] = useState([]); const { getRouteGroupsMap } = useRouteGroupsMatcher(); + const { selectedAlertmanager } = useAlertmanager(); - const alertManagers = useAlertManagersByPermission('notification'); - const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); + const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? ''); - const contactPointsState = useGetContactPointsState(alertManagerSourceName ?? ''); - - const { result, config, loading: resultLoading, error: resultError } = useAlertmanagerConfig(alertManagerSourceName); + const { result, config, loading: resultLoading, error: resultError } = useAlertmanagerConfig(selectedAlertmanager); const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery( - { amSourceName: alertManagerSourceName ?? '' }, - { skip: !alertManagerSourceName } + { amSourceName: selectedAlertmanager ?? '' }, + { skip: !selectedAlertmanager } ); const receivers = config?.receivers ?? []; @@ -113,7 +108,7 @@ const AmRoutes = () => { if (!rootRoute) { return; } - const newRouteTree = mergePartialAmRouteWithRouteTree(alertManagerSourceName ?? '', partialRoute, rootRoute); + const newRouteTree = mergePartialAmRouteWithRouteTree(selectedAlertmanager ?? '', partialRoute, rootRoute); updateRouteTree(newRouteTree); } @@ -130,7 +125,7 @@ const AmRoutes = () => { return; } - const newRouteTree = addRouteToParentRoute(alertManagerSourceName ?? '', partialRoute, parentRoute, rootRoute); + const newRouteTree = addRouteToParentRoute(selectedAlertmanager ?? '', partialRoute, parentRoute, rootRoute); updateRouteTree(newRouteTree); } @@ -151,14 +146,14 @@ const AmRoutes = () => { }, }, oldConfig: result, - alertManagerSourceName: alertManagerSourceName!, + alertManagerSourceName: selectedAlertmanager!, successMessage: 'Updated notification policies', refetch: true, }) ) .unwrap() .then(() => { - if (alertManagerSourceName) { + if (selectedAlertmanager) { refetchAlertGroups(); } closeEditModal(); @@ -173,7 +168,7 @@ const AmRoutes = () => { // edit, add, delete modals const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(receivers, handleAdd, updatingTree); const [editModal, openEditModal, closeEditModal] = useEditPolicyModal( - alertManagerSourceName ?? '', + selectedAlertmanager ?? '', receivers, handleSave, updatingTree @@ -183,15 +178,11 @@ const AmRoutes = () => { useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState)); - if (!alertManagerSourceName) { - return ( - - - - ); + if (!selectedAlertmanager) { + return null; } - const vanillaPrometheusAlertManager = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName); + const vanillaPrometheusAlertManager = isVanillaPrometheusAlertManagerDataSource(selectedAlertmanager); const readOnlyPolicies = vanillaPrometheusAlertManager || isProvisioned; const readOnlyMuteTimings = vanillaPrometheusAlertManager; @@ -205,11 +196,6 @@ const AmRoutes = () => { return ( <> - { <> {policyTreeTabActive && ( <> - + {isProvisioned && } {rootRoute && ( @@ -258,7 +244,7 @@ const AmRoutes = () => { alertGroups={alertGroups ?? []} contactPointsState={contactPointsState.receivers} readOnly={readOnlyPolicies} - alertManagerSourceName={alertManagerSourceName} + alertManagerSourceName={selectedAlertmanager} onAddPolicy={openAddModal} onEditPolicy={openEditModal} onDeletePolicy={openDeleteModal} @@ -275,7 +261,7 @@ const AmRoutes = () => { )} {muteTimingsTabActive && ( - + )} )} @@ -344,12 +330,10 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap): QueryParamValues { }; } -function NotificationPoliciesPage() { - return ( - - - - ); -} +const NotificationPoliciesPage = () => ( + + + +); export default withErrorBoundary(NotificationPoliciesPage, { style: 'page' }); diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index 0b5b6979e16..9baa0238952 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -7,18 +7,18 @@ const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-poi import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { AlertingFeature } from './features'; // TODO add pagenav back in – what are we missing if we don't specify it? const ContactPoints = (props: GrafanaRouteComponentProps): JSX.Element => ( - + - + ); export default withErrorBoundary(ContactPoints, { style: 'page' }); diff --git a/public/app/features/alerting/unified/Silences.tsx b/public/app/features/alerting/unified/Silences.tsx index eaf54214b13..1d4dfaf8e25 100644 --- a/public/app/features/alerting/unified/Silences.tsx +++ b/public/app/features/alerting/unified/Silences.tsx @@ -1,50 +1,42 @@ import React, { useCallback, useEffect } from 'react'; -import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom'; +import { Route, RouteChildrenProps, Switch } from 'react-router-dom'; import { Alert, withErrorBoundary } from '@grafana/ui'; import { Silence } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; import { featureDiscoveryApi } from './api/featureDiscoveryApi'; -import { AlertManagerPicker } from './components/AlertManagerPicker'; -import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; -import { NoAlertManagerWarning } from './components/NoAlertManagerWarning'; import SilencesEditor from './components/silences/SilencesEditor'; import SilencesTable from './components/silences/SilencesTable'; -import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; import { useSilenceNavData } from './hooks/useSilenceNavData'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { useAlertmanager } from './state/AlertmanagerContext'; import { fetchAmAlertsAction, fetchSilencesAction } from './state/actions'; import { SILENCES_POLL_INTERVAL_MS } from './utils/constants'; import { AsyncRequestState, initialAsyncRequestState } from './utils/redux'; const Silences = () => { - const alertManagers = useAlertManagersByPermission('instance'); - const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); + const { selectedAlertmanager } = useAlertmanager(); const dispatch = useDispatch(); const silences = useUnifiedAlertingSelector((state) => state.silences); const alertsRequests = useUnifiedAlertingSelector((state) => state.amAlerts); - const alertsRequest = alertManagerSourceName - ? alertsRequests[alertManagerSourceName] || initialAsyncRequestState + const alertsRequest = selectedAlertmanager + ? alertsRequests[selectedAlertmanager] || initialAsyncRequestState : undefined; - const location = useLocation(); - const pageNav = useSilenceNavData(); - const isRoot = location.pathname.endsWith('/alerting/silences'); - const { currentData: amFeatures } = featureDiscoveryApi.useDiscoverAmFeaturesQuery( - { amSourceName: alertManagerSourceName ?? '' }, - { skip: !alertManagerSourceName } + { amSourceName: selectedAlertmanager ?? '' }, + { skip: !selectedAlertmanager } ); useEffect(() => { function fetchAll() { - if (alertManagerSourceName) { - dispatch(fetchSilencesAction(alertManagerSourceName)); - dispatch(fetchAmAlertsAction(alertManagerSourceName)); + if (selectedAlertmanager) { + dispatch(fetchSilencesAction(selectedAlertmanager)); + dispatch(fetchAmAlertsAction(selectedAlertmanager)); } } fetchAll(); @@ -52,35 +44,23 @@ const Silences = () => { return () => { clearInterval(interval); }; - }, [alertManagerSourceName, dispatch]); + }, [selectedAlertmanager, dispatch]); const { result, loading, error }: AsyncRequestState = - (alertManagerSourceName && silences[alertManagerSourceName]) || initialAsyncRequestState; + (selectedAlertmanager && silences[selectedAlertmanager]) || initialAsyncRequestState; const getSilenceById = useCallback((id: string) => result && result.find((silence) => silence.id === id), [result]); const mimirLazyInitError = error?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit; - if (!alertManagerSourceName) { - return isRoot ? ( - - - - ) : ( - - ); + if (!selectedAlertmanager) { + return null; } return ( - - - + <> + {mimirLazyInitError && ( @@ -104,11 +84,11 @@ const Silences = () => { - + {({ match }: RouteChildrenProps<{ id: string }>) => { @@ -116,7 +96,7 @@ const Silences = () => { match?.params.id && ( ) ); @@ -124,8 +104,18 @@ const Silences = () => { )} - + ); }; -export default withErrorBoundary(Silences, { style: 'page' }); +function SilencesPage() { + const pageNav = useSilenceNavData(); + + return ( + + + + ); +} + +export default withErrorBoundary(SilencesPage, { style: 'page' }); diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx index d3b81252194..7730fe76c05 100644 --- a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -2,35 +2,35 @@ import { css } from '@emotion/css'; import React, { useMemo } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Field, Select, useStyles2 } from '@grafana/ui'; +import { InlineField, Select, useStyles2 } from '@grafana/ui'; +import { useAlertmanager } from '../state/AlertmanagerContext'; import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; interface Props { - onChange: (alertManagerSourceName: string) => void; - current?: string; disabled?: boolean; - dataSources: AlertManagerDataSource[]; } function getAlertManagerLabel(alertManager: AlertManagerDataSource) { return alertManager.name === GRAFANA_RULES_SOURCE_NAME ? 'Grafana' : alertManager.name.slice(0, 37); } -export const AlertManagerPicker = ({ onChange, current, dataSources, disabled = false }: Props) => { +export const AlertManagerPicker = ({ disabled = false }: Props) => { const styles = useStyles2(getStyles); + const { selectedAlertmanager, availableAlertManagers, setSelectedAlertmanager } = useAlertmanager(); + const options: Array> = useMemo(() => { - return dataSources.map((ds) => ({ + return availableAlertManagers.map((ds) => ({ label: getAlertManagerLabel(ds), value: ds.name, imgUrl: ds.imgUrl, meta: ds.meta, })); - }, [dataSources]); + }, [availableAlertManagers]); return ( - value.value && onChange(value.value)} + onChange={(value) => { + if (value?.value) { + setSelectedAlertmanager(value.value); + } + }} options={options} maxMenuHeight={500} noOptionsMessage="No datasources found" - value={current} + value={selectedAlertmanager} getOptionLabel={(o) => o.label} /> - + ); }; const getStyles = (theme: GrafanaTheme2) => ({ field: css` - margin-bottom: ${theme.spacing(4)}; + margin: 0; `, }); diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx index 9014579cce7..7c80f6717ba 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx @@ -1,22 +1,30 @@ import Mousetrap from 'mousetrap'; -import React, { useEffect, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useState } from 'react'; import { Features, ToggleFeatures } from 'react-enable'; +import { useLocation } from 'react-use'; import { NavModelItem } from '@grafana/data'; import { Page } from 'app/core/components/Page/Page'; import FEATURES from '../features'; +import { AlertmanagerProvider, useAlertmanager } from '../state/AlertmanagerContext'; -interface Props { - pageId: string; - isLoading?: boolean; - pageNav?: NavModelItem; -} +import { AlertManagerPicker } from './AlertManagerPicker'; +import { NoAlertManagerWarning } from './NoAlertManagerWarning'; const SHOW_TOGGLES_KEY_COMBO = 'ctrl+1'; const combokeys = new Mousetrap(document.body); -export const AlertingPageWrapper = ({ children, pageId, pageNav, isLoading }: React.PropsWithChildren) => { +/** + * This is the main alerting page wrapper, used by the alertmanager page wrapper and the alert rules list view + */ +interface AlertingPageWrapperProps extends PropsWithChildren { + pageId: string; + isLoading?: boolean; + pageNav?: NavModelItem; + actions?: React.ReactNode; +} +export const AlertingPageWrapper = ({ children, pageId, pageNav, actions, isLoading }: AlertingPageWrapperProps) => { const [showFeatureToggle, setShowFeatureToggles] = useState(false); useEffect(() => { @@ -31,10 +39,53 @@ export const AlertingPageWrapper = ({ children, pageId, pageNav, isLoading }: Re return ( - + {children} {showFeatureToggle ? : null} ); }; + +/** + * This wrapper is for pages that use the Alertmanager API + */ +interface AlertmanagerPageWrapperProps extends AlertingPageWrapperProps { + accessType: 'instance' | 'notification'; +} +export const AlertmanagerPageWrapper = ({ children, accessType, ...props }: AlertmanagerPageWrapperProps) => { + const disableAlertmanager = useIsDisabledAlertmanagerSelection(); + + return ( + + }> + {children} + + + ); +}; + +/** + * This function tells us when we want to disable the alertmanager picker + * It's not great... + */ +function useIsDisabledAlertmanagerSelection() { + const location = useLocation(); + const disabledPathSegment = ['/edit', '/new']; + + return disabledPathSegment.some((match) => location?.pathname?.includes(match)); +} + +/** + * This component will render an error message if the user doesn't have sufficient permissions or if the requested + * alertmanager doesn't exist + */ +const AlertManagerPagePermissionsCheck = ({ children }: PropsWithChildren) => { + const { availableAlertManagers, selectedAlertmanager } = useAlertmanager(); + + if (!selectedAlertmanager) { + return ; + } + + return <>{children}; +}; diff --git a/public/app/features/alerting/unified/components/NoAlertManagerWarning.tsx b/public/app/features/alerting/unified/components/NoAlertManagerWarning.tsx index 209c6adf171..cea1106b01d 100644 --- a/public/app/features/alerting/unified/components/NoAlertManagerWarning.tsx +++ b/public/app/features/alerting/unified/components/NoAlertManagerWarning.tsx @@ -2,11 +2,8 @@ import React from 'react'; import { Alert } from '@grafana/ui'; -import { useAlertManagerSourceName } from '../hooks/useAlertManagerSourceName'; import { AlertManagerDataSource } from '../utils/datasource'; -import { AlertManagerPicker } from './AlertManagerPicker'; - interface Props { availableAlertManagers: AlertManagerDataSource[]; } @@ -18,25 +15,14 @@ const NoAlertManagersAvailable = () => ( ); const OtherAlertManagersAvailable = () => ( - - Selected Alertmanager no longer exists or you may not have permission to access it. + + The selected Alertmanager no longer exists or you may not have permission to access it. You can select a different + Alertmanager from the dropdown. ); export const NoAlertManagerWarning = ({ availableAlertManagers }: Props) => { - const [_, setAlertManagerSourceName] = useAlertManagerSourceName(availableAlertManagers); const hasOtherAMs = availableAlertManagers.length > 0; - return ( -
- {hasOtherAMs ? ( - <> - - - - ) : ( - - )} -
- ); + return
{hasOtherAMs ? : }
; }; diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx index 6e4c81547e3..43ea010454f 100644 --- a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx +++ b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx @@ -26,6 +26,7 @@ import { someCloudAlertManagerConfig, someCloudAlertManagerStatus, } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { getAllDataSources } from '../../utils/config'; import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../../utils/constants'; import { DataSourceType } from '../../utils/datasource'; @@ -55,7 +56,9 @@ const renderAdminPage = (alertManagerSourceName?: string) => { return render( - + + + ); }; diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx index b5d4261ba13..294ba9dd0c4 100644 --- a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx +++ b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx @@ -5,9 +5,8 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Alert, useStyles2 } from '@grafana/ui'; import { useDispatch } from 'app/types'; -import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from '../../hooks/useAlertManagerSources'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; import { deleteAlertManagerConfigAction, fetchAlertManagerConfigAction, @@ -15,7 +14,6 @@ import { } from '../../state/actions'; import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; -import { AlertManagerPicker } from '../AlertManagerPicker'; import AlertmanagerConfigSelector, { ValidAmConfigOption } from './AlertmanagerConfigSelector'; import { ConfigEditor } from './ConfigEditor'; @@ -26,14 +24,13 @@ export interface FormValues { export default function AlertmanagerConfig(): JSX.Element { const dispatch = useDispatch(); - const alertManagers = useAlertManagersByPermission('notification'); - const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false); const { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig); const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig); + const { selectedAlertmanager } = useAlertmanager(); - const readOnly = alertManagerSourceName ? isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName) : false; + const readOnly = selectedAlertmanager ? isVanillaPrometheusAlertManagerDataSource(selectedAlertmanager) : false; const styles = useStyles2(getStyles); const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs); @@ -44,17 +41,17 @@ export default function AlertmanagerConfig(): JSX.Element { result: config, loading: isLoadingConfig, error: loadingError, - } = (alertManagerSourceName && configRequests[alertManagerSourceName]) || initialAsyncRequestState; + } = (selectedAlertmanager && configRequests[selectedAlertmanager]) || initialAsyncRequestState; useEffect(() => { - if (alertManagerSourceName) { - dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); + if (selectedAlertmanager) { + dispatch(fetchAlertManagerConfigAction(selectedAlertmanager)); } - }, [alertManagerSourceName, dispatch]); + }, [selectedAlertmanager, dispatch]); const resetConfig = () => { - if (alertManagerSourceName) { - dispatch(deleteAlertManagerConfigAction(alertManagerSourceName)); + if (selectedAlertmanager) { + dispatch(deleteAlertManagerConfigAction(selectedAlertmanager)); } setShowConfirmDeleteAMConfig(false); }; @@ -76,12 +73,12 @@ export default function AlertmanagerConfig(): JSX.Element { const loading = isDeleting || isLoadingConfig || isSaving; const onSubmit = (values: FormValues) => { - if (alertManagerSourceName && config) { + if (selectedAlertmanager && config) { dispatch( updateAlertManagerConfigAction({ newConfig: JSON.parse(values.configJSON), oldConfig: config, - alertManagerSourceName, + alertManagerSourceName: selectedAlertmanager, successMessage: 'Alertmanager configuration updated.', refetch: true, }) @@ -91,11 +88,6 @@ export default function AlertmanagerConfig(): JSX.Element { return (
- {loadingError && !loading && ( <> - {alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && ( + {selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME && ( )} - {isDeleting && alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME && ( + {isDeleting && selectedAlertmanager !== GRAFANA_RULES_SOURCE_NAME && ( It might take a while... )} - {alertManagerSourceName && config && ( + {selectedAlertmanager && config && ( onSubmit(values)} readOnly={readOnly} loading={loading} - alertManagerSourceName={alertManagerSourceName} + alertManagerSourceName={selectedAlertmanager} showConfirmDeleteAMConfig={showConfirmDeleteAMConfig} onReset={() => setShowConfirmDeleteAMConfig(true)} onConfirmReset={resetConfig} diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx index 2950864ac2b..4b7afe05247 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx @@ -6,10 +6,7 @@ import { Button, useStyles2 } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types'; -import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from '../../hooks/useAlertManagerSources'; import { getFiltersFromUrlParams } from '../../utils/misc'; -import { AlertManagerPicker } from '../AlertManagerPicker'; import { AlertStateFilter } from './AlertStateFilter'; import { GroupBy } from './GroupBy'; @@ -25,8 +22,6 @@ export const AlertGroupFilter = ({ groups }: Props) => { const { groupBy = [], queryString, alertState } = getFiltersFromUrlParams(queryParams); const matcherFilterKey = `matcher-${filterKey}`; - const alertManagers = useAlertManagersByPermission('instance'); - const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); const styles = useStyles2(getStyles); const clearFilters = () => { @@ -42,11 +37,6 @@ export const AlertGroupFilter = ({ groups }: Props) => { return (
-
{ - locationService.push( - '/alerting/notifications' + - (alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '') - ); - - return render( - - - - ); -}; - const dataSources = { alertManager: mockDataSource({ name: 'CloudManager', @@ -96,6 +84,21 @@ const dataSources = { }), }; +const renderReceivers = (alertManagerSourceName?: string) => { + locationService.push( + '/alerting/notifications' + + (alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '') + ); + + return render( + + + + + + ); +}; + const ui = { newContactPointButton: byRole('link', { name: /add contact point/i }), saveContactButton: byRole('button', { name: /save contact point/i }), @@ -116,7 +119,6 @@ const ui = { channelFormContainer: byTestId('item-container'), - notificationError: byTestId('receivers-notification-error'), contactPointsCollapseToggle: byTestId('collapse-toggle'), inputs: { @@ -210,25 +212,6 @@ describe('Receivers', () => { expect(mocks.api.fetchConfig).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME); expect(mocks.api.fetchNotifiers).toHaveBeenCalledTimes(1); expect(locationService.getSearchObject()[ALERTMANAGER_NAME_QUERY_KEY]).toEqual(undefined); - - // select external cloud alertmanager, check that data is retrieved and contents are rendered as appropriate - await clickSelectOption(ui.alertManagerPicker.get(), 'CloudManager'); - await byText('cloud-receiver').find(); - expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(2); - expect(mocks.api.fetchConfig).toHaveBeenLastCalledWith('CloudManager'); - - await ui.receiversTable.find(); - templatesTable = await ui.templatesTable.find(); - templateRows = templatesTable.querySelectorAll('tbody tr'); - expect(templateRows[0]).toHaveTextContent('foo template'); - expect(templateRows).toHaveLength(1); - receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); - expect(receiverRows[0]).toHaveTextContent('cloud-receiver'); - expect(receiverRows).toHaveLength(1); - expect(locationService.getSearchObject()[ALERTMANAGER_NAME_QUERY_KEY]).toEqual('CloudManager'); - - //should not render any notification error - expect(ui.notificationError.query()).not.toBeInTheDocument(); }); it('Grafana receiver can be tested', async () => { @@ -593,9 +576,6 @@ describe('Receivers', () => { // await ui.receiversTable.find(); - //should render notification error - expect(ui.notificationError.query()).toBeInTheDocument(); - expect(ui.notificationError.get()).toHaveTextContent('1 error with contact points'); const receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(receiverRows[0]).toHaveTextContent('1 error'); @@ -665,9 +645,6 @@ describe('Receivers', () => { // await ui.receiversTable.find(); - //should render notification error - expect(ui.notificationError.query()).toBeInTheDocument(); - expect(ui.notificationError.get()).toHaveTextContent('1 error with contact points'); const receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); expect(receiverRows[0]).toHaveTextContent('1 error'); @@ -700,8 +677,6 @@ describe('Receivers', () => { renderReceivers(); await ui.receiversTable.find(); - //should not render notification error - expect(ui.notificationError.query()).not.toBeInTheDocument(); //contact points are not expandable expect(ui.contactPointsCollapseToggle.query()).not.toBeInTheDocument(); //should render receivers, only one dynamic table @@ -723,8 +698,6 @@ describe('Receivers', () => { renderReceivers(); await ui.receiversTable.find(); - //should not render notification error - expect(ui.notificationError.query()).not.toBeInTheDocument(); //contact points are not expandable expect(ui.contactPointsCollapseToggle.query()).not.toBeInTheDocument(); //should render receivers, only one dynamic table diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.tsx index fb003777276..7f1efc7b76f 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.tsx @@ -1,23 +1,15 @@ -import { css } from '@emotion/css'; -import pluralize from 'pluralize'; import React, { useEffect } from 'react'; -import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom'; +import { Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Stack } from '@grafana/experimental'; -import { Alert, Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; -import { ContactPointsState, useDispatch } from 'app/types'; +import { Alert, LoadingPlaceholder } from '@grafana/ui'; +import { useDispatch } from 'app/types'; -import { useGetContactPointsState } from '../../api/receiversApi'; -import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from '../../hooks/useAlertManagerSources'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; import { fetchAlertManagerConfigAction, fetchGrafanaNotifiersAction } from '../../state/actions'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; -import { AlertManagerPicker } from '../AlertManagerPicker'; import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; -import { NoAlertManagerWarning } from '../NoAlertManagerWarning'; import { DuplicateTemplateView } from '../receivers/DuplicateTemplateView'; import { EditReceiverView } from '../receivers/EditReceiverView'; import { EditTemplateView } from '../receivers/EditTemplateView'; @@ -30,27 +22,9 @@ export interface NotificationErrorProps { errorCount: number; } -function NotificationError({ errorCount }: NotificationErrorProps) { - const styles = useStyles2(getStyles); - - return ( -
- - - -
{`${errorCount} ${pluralize('error', errorCount)} with contact points`}
-
-
{'Some alert notifications might not be delivered'}
-
-
- ); -} - const Receivers = () => { - const alertManagers = useAlertManagersByPermission('notification'); - const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); + const { selectedAlertmanager: alertManagerSourceName } = useAlertmanager(); const dispatch = useDispatch(); - const styles = useStyles2(getStyles); const location = useLocation(); const isRoot = location.pathname.endsWith('/alerting/notifications'); @@ -65,7 +39,7 @@ const Receivers = () => { const receiverTypes = useUnifiedAlertingSelector((state) => state.grafanaNotifiers); const shouldLoadConfig = isRoot || !config; - const shouldRenderNotificationStatus = isRoot; + // const shouldRenderNotificationStatus = isRoot; useEffect(() => { if (alertManagerSourceName && shouldLoadConfig) { @@ -82,32 +56,12 @@ const Receivers = () => { } }, [alertManagerSourceName, dispatch, receiverTypes]); - const contactPointsState: ContactPointsState = useGetContactPointsState(alertManagerSourceName ?? ''); - const integrationsErrorCount = contactPointsState?.errorCount ?? 0; - - const disableAmSelect = !isRoot; - if (!alertManagerSourceName) { - return isRoot ? ( - - ) : ( - - ); + return null; } return ( <> -
- - {shouldRenderNotificationStatus && integrationsErrorCount > 0 && ( - - )} -
{error && !loading && ( {error.message || 'Unknown error.'} @@ -169,13 +123,3 @@ const Receivers = () => { }; export default Receivers; - -const getStyles = (theme: GrafanaTheme2) => ({ - error: css` - color: ${theme.colors.error.text}; - `, - headingContainer: css` - display: flex; - justify-content: space-between; - `, -}); diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx index 92b987db209..9718fe60190 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { Alert, Button, Field, FieldSet, Input, LinkButton, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { AlertmanagerConfig, @@ -11,17 +11,14 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; -import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName'; -import { useAlertManagersByPermission } from '../../hooks/useAlertManagerSources'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; import { updateAlertManagerConfigAction } from '../../state/actions'; import { MuteTimingFields } from '../../types/mute-timing-form'; import { renameMuteTimings } from '../../utils/alertmanager'; import { makeAMLink } from '../../utils/misc'; import { createMuteTiming, defaultTimeInterval } from '../../utils/mute-timings'; import { initialAsyncRequestState } from '../../utils/redux'; -import { AlertManagerPicker } from '../AlertManagerPicker'; -import { AlertingPageWrapper } from '../AlertingPageWrapper'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; import { MuteTimingTimeInterval } from './MuteTimingTimeInterval'; @@ -58,14 +55,9 @@ const useDefaultValues = (muteTiming?: MuteTimeInterval): MuteTimingFields => { }; }; -const defaultPageNav: Partial = { - icon: 'sitemap', -}; - const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) => { const dispatch = useDispatch(); - const alertManagers = useAlertManagersByPermission('notification'); - const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers); + const { selectedAlertmanager } = useAlertmanager(); const styles = useStyles2(getStyles); const [updating, setUpdating] = useState(false); @@ -73,7 +65,7 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = const defaultAmCortexConfig = { alertmanager_config: {}, template_files: {} }; const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); const { result = defaultAmCortexConfig } = - (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + (selectedAlertmanager && amConfigs[selectedAlertmanager]) || initialAsyncRequestState; const config: AlertmanagerConfig = result?.alertmanager_config ?? {}; const defaultValues = useDefaultValues(muteTiming); @@ -102,7 +94,7 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = updateAlertManagerConfigAction({ newConfig, oldConfig: result, - alertManagerSourceName: alertManagerSourceName!, + alertManagerSourceName: selectedAlertmanager!, successMessage: 'Mute timing saved', redirectPath: '/alerting/routes/', redirectSearch: 'tab=mute_timings', @@ -117,20 +109,7 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = }; return ( - - + <> {provenance && } {loading && } {showError && } @@ -168,7 +147,7 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = type="button" variant="secondary" fill="outline" - href={makeAMLink('/alerting/routes/', alertManagerSourceName, { tab: 'mute_timings' })} + href={makeAMLink('/alerting/routes/', selectedAlertmanager, { tab: 'mute_timings' })} disabled={updating} > Cancel @@ -177,7 +156,7 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) = )} - + ); }; diff --git a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.test.tsx b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.test.tsx index 38856103425..a0196a35dfe 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.test.tsx @@ -9,6 +9,7 @@ import { Button } from '@grafana/ui'; import { TestProvider } from '../../../../../../test/helpers/TestProvider'; import { RouteWithID } from '../../../../../plugins/datasource/alertmanager/types'; import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { FormAmRoute } from '../../types/amroutes'; import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types'; @@ -142,12 +143,14 @@ function renderRouteForm( onSubmit: (route: Partial) => void = noop ) { render( - Update default policy} - onSubmit={onSubmit} - receivers={receivers} - route={route} - />, + + Update default policy} + onSubmit={onSubmit} + receivers={receivers} + route={route} + /> + , { wrapper: TestProvider } ); } diff --git a/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.test.tsx b/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.test.tsx deleted file mode 100644 index 2aad7ab0442..00000000000 --- a/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; -import React from 'react'; -import { MemoryRouter, Router } from 'react-router-dom'; - -import store from 'app/core/store'; - -import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY } from '../utils/constants'; -import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; - -import { useAlertManagerSourceName } from './useAlertManagerSourceName'; - -const grafanaAm: AlertManagerDataSource = { - name: GRAFANA_RULES_SOURCE_NAME, - imgUrl: '', -}; - -const externalAmProm: AlertManagerDataSource = { - name: 'PrometheusAm', - imgUrl: '', -}; - -const externalAmMimir: AlertManagerDataSource = { - name: 'MimirAm', - imgUrl: '', -}; - -describe('useAlertManagerSourceName', () => { - it('Should return undefined alert manager name when there are no available alert managers', () => { - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - const { result } = renderHook(() => useAlertManagerSourceName([]), { wrapper }); - - const [alertManager] = result.current; - - expect(alertManager).toBeUndefined(); - }); - - it('Should return Grafana AM when it is available and no alert manager query param exists', () => { - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - - const availableAMs = [grafanaAm, externalAmProm, externalAmMimir]; - const { result } = renderHook(() => useAlertManagerSourceName(availableAMs), { wrapper }); - - const [alertManager] = result.current; - - expect(alertManager).toBe(grafanaAm.name); - }); - - it('Should return alert manager included in the query param when available', () => { - const history = createMemoryHistory(); - history.push({ search: `alertmanager=${externalAmProm.name}` }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - - const availableAMs = [grafanaAm, externalAmProm, externalAmMimir]; - const { result } = renderHook(() => useAlertManagerSourceName(availableAMs), { wrapper }); - - const [alertManager] = result.current; - - expect(alertManager).toBe(externalAmProm.name); - }); - - it('Should return undefined if alert manager included in the query is not available', () => { - const history = createMemoryHistory(); - history.push({ search: `alertmanager=Not available external AM` }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - - const availableAMs = [grafanaAm, externalAmProm, externalAmMimir]; - - const { result } = renderHook(() => useAlertManagerSourceName(availableAMs), { wrapper }); - - const [alertManager] = result.current; - - expect(alertManager).toBe(undefined); - }); - - it('Should return alert manager from store if available and query is empty', () => { - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - - const availableAMs = [grafanaAm, externalAmProm, externalAmMimir]; - store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, externalAmProm.name); - - const { result } = renderHook(() => useAlertManagerSourceName(availableAMs), { wrapper }); - - const [alertManager] = result.current; - - expect(alertManager).toBe(externalAmProm.name); - }); - - it('Should prioritize the alert manager from query over store', () => { - const history = createMemoryHistory(); - history.push({ search: `alertmanager=${externalAmProm.name}` }); - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - - const availableAMs = [grafanaAm, externalAmProm, externalAmMimir]; - store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, externalAmMimir.name); - - const { result } = renderHook(() => useAlertManagerSourceName(availableAMs), { wrapper }); - - const [alertManager] = result.current; - - expect(alertManager).toBe(externalAmProm.name); - }); -}); diff --git a/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts b/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts deleted file mode 100644 index b23b4b1a003..00000000000 --- a/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useCallback } from 'react'; - -import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import store from 'app/core/store'; - -import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants'; -import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; - -function useIsAlertManagerAvailable(availableAlertManagers: AlertManagerDataSource[]) { - return useCallback( - (alertManagerName: string) => { - const availableAlertManagersNames = availableAlertManagers.map((am) => am.name); - return availableAlertManagersNames.includes(alertManagerName); - }, - [availableAlertManagers] - ); -} - -/* This will return am name either from query params or from local storage or a default (grafana). - * Due to RBAC permissions Grafana Managed Alert manager or external alert managers may not be available - * In the worst case neither GMA nor external alert manager is available - */ -export function useAlertManagerSourceName( - availableAlertManagers: AlertManagerDataSource[] -): [string | undefined, (alertManagerSourceName: string) => void] { - const [queryParams, updateQueryParams] = useQueryParams(); - const isAlertManagerAvailable = useIsAlertManagerAvailable(availableAlertManagers); - - const update = useCallback( - (alertManagerSourceName: string) => { - if (!isAlertManagerAvailable(alertManagerSourceName)) { - return; - } - if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) { - store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); - updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: null }); - } else { - store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, alertManagerSourceName); - updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: alertManagerSourceName }); - } - }, - [updateQueryParams, isAlertManagerAvailable] - ); - - const querySource = queryParams[ALERTMANAGER_NAME_QUERY_KEY]; - - if (querySource && typeof querySource === 'string') { - if (isAlertManagerAvailable(querySource)) { - return [querySource, update]; - } else { - // non existing alertmanager - return [undefined, update]; - } - } - - const storeSource = store.get(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); - if (storeSource && typeof storeSource === 'string' && isAlertManagerAvailable(storeSource)) { - update(storeSource); - return [storeSource, update]; - } - - if (isAlertManagerAvailable(GRAFANA_RULES_SOURCE_NAME)) { - return [GRAFANA_RULES_SOURCE_NAME, update]; - } - - return [undefined, update]; -} diff --git a/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts b/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts index 2f702ad3836..8047a30f00b 100644 --- a/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts +++ b/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts @@ -3,20 +3,18 @@ import { useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; import { AlertmanagerConfig } from 'app/plugins/datasource/alertmanager/types'; +import { useAlertmanager } from '../state/AlertmanagerContext'; import { timeIntervalToString } from '../utils/alertmanager'; import { initialAsyncRequestState } from '../utils/redux'; -import { useAlertManagerSourceName } from './useAlertManagerSourceName'; -import { useAlertManagersByPermission } from './useAlertManagerSources'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; export function useMuteTimingOptions(): Array> { - const alertManagers = useAlertManagersByPermission('notification'); - const [alertManagerSourceName] = useAlertManagerSourceName(alertManagers); + const { selectedAlertmanager } = useAlertmanager(); const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); return useMemo(() => { - const { result } = (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + const { result } = (selectedAlertmanager && amConfigs[selectedAlertmanager]) || initialAsyncRequestState; const config: AlertmanagerConfig = result?.alertmanager_config ?? {}; const muteTimingsOptions: Array> = @@ -27,5 +25,5 @@ export function useMuteTimingOptions(): Array> { })) ?? []; return muteTimingsOptions; - }, [alertManagerSourceName, amConfigs]); + }, [selectedAlertmanager, amConfigs]); } diff --git a/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx b/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx new file mode 100644 index 00000000000..363ca104aa7 --- /dev/null +++ b/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx @@ -0,0 +1,120 @@ +import { renderHook } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { MemoryRouter, Router } from 'react-router-dom'; + +import store from 'app/core/store'; + +import * as useAlertManagerSources from '../hooks/useAlertManagerSources'; +import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY } from '../utils/constants'; +import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; + +import { AlertmanagerProvider, useAlertmanager } from './AlertmanagerContext'; + +const grafanaAm: AlertManagerDataSource = { + name: GRAFANA_RULES_SOURCE_NAME, + imgUrl: '', +}; + +const externalAmProm: AlertManagerDataSource = { + name: 'PrometheusAm', + imgUrl: '', +}; + +const externalAmMimir: AlertManagerDataSource = { + name: 'MimirAm', + imgUrl: '', +}; + +describe('useAlertmanager', () => { + it('Should return undefined alert manager name when there are no available alert managers', () => { + jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([]); + const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook(() => useAlertmanager(), { wrapper }); + expect(result.current.selectedAlertmanager).toBe(undefined); + }); + + it('Should return Grafana AM when it is available and no alert manager query param exists', () => { + jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([grafanaAm]); + const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook(() => useAlertmanager(), { wrapper }); + expect(result.current.selectedAlertmanager).toBe(grafanaAm.name); + }); + + it('Should return alert manager included in the query param when available', () => { + jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([externalAmProm]); + + const history = createMemoryHistory(); + history.push({ search: `alertmanager=${externalAmProm.name}` }); + + const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook(() => useAlertmanager(), { wrapper }); + expect(result.current.selectedAlertmanager).toBe(externalAmProm.name); + }); + + it('Should return undefined if alert manager included in the query is not available', () => { + jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([]); + + const history = createMemoryHistory(); + history.push({ search: `alertmanager=Not available external AM` }); + + const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook(() => useAlertmanager(), { wrapper }); + expect(result.current.selectedAlertmanager).toBe(undefined); + }); + + it('Should return alert manager from store if available and query is empty', () => { + jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([externalAmProm]); + + const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + + store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, externalAmProm.name); + + const { result } = renderHook(() => useAlertmanager(), { wrapper }); + expect(result.current.selectedAlertmanager).toBe(externalAmProm.name); + }); + + it('Should prioritize the alert manager from query over store', () => { + jest + .spyOn(useAlertManagerSources, 'useAlertManagersByPermission') + .mockReturnValueOnce([externalAmProm, externalAmMimir]); + + const history = createMemoryHistory(); + history.push({ search: `alertmanager=${externalAmProm.name}` }); + + const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + + store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, externalAmMimir.name); + + const { result } = renderHook(() => useAlertmanager(), { wrapper }); + expect(result.current.selectedAlertmanager).toBe(externalAmProm.name); + }); +}); diff --git a/public/app/features/alerting/unified/state/AlertmanagerContext.tsx b/public/app/features/alerting/unified/state/AlertmanagerContext.tsx new file mode 100644 index 00000000000..8ae6653118f --- /dev/null +++ b/public/app/features/alerting/unified/state/AlertmanagerContext.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import store from 'app/core/store'; + +import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources'; +import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants'; +import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; + +interface Context { + selectedAlertmanager: string | undefined; + availableAlertManagers: AlertManagerDataSource[]; + setSelectedAlertmanager: (name: string) => void; +} + +const AlertmanagerContext = React.createContext(undefined); + +interface Props extends React.PropsWithChildren { + accessType: 'instance' | 'notification'; +} + +const AlertmanagerProvider = ({ children, accessType }: Props) => { + const [queryParams, updateQueryParams] = useQueryParams(); + const availableAlertManagers = useAlertManagersByPermission(accessType); + + const updateSelectedAlertmanager = React.useCallback( + (selectedAlertManager: string) => { + if (!isAlertManagerAvailable(availableAlertManagers, selectedAlertManager)) { + return; + } + + if (selectedAlertManager === GRAFANA_RULES_SOURCE_NAME) { + store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); + updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: null }); + } else { + store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, selectedAlertManager); + updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: selectedAlertManager }); + } + }, + [availableAlertManagers, updateQueryParams] + ); + + const sourceFromQuery = queryParams[ALERTMANAGER_NAME_QUERY_KEY]; + const sourceFromStore = store.get(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); + const defaultSource = GRAFANA_RULES_SOURCE_NAME; + + // queryParam > localStorage > default + const desiredAlertmanager = sourceFromQuery ?? sourceFromStore ?? defaultSource; + const selectedAlertmanager = isAlertManagerAvailable(availableAlertManagers, desiredAlertmanager) + ? desiredAlertmanager + : undefined; + + const value: Context = { + selectedAlertmanager, + availableAlertManagers, + setSelectedAlertmanager: updateSelectedAlertmanager, + }; + + return {children}; +}; + +function useAlertmanager() { + const context = React.useContext(AlertmanagerContext); + + if (context === undefined) { + throw new Error('useAlertmanager must be used within a AlertmanagerContext'); + } + + return context; +} + +export { AlertmanagerProvider, useAlertmanager }; + +function isAlertManagerAvailable(availableAlertManagers: AlertManagerDataSource[], alertManagerName: string) { + const availableAlertManagersNames = availableAlertManagers.map((am) => am.name); + return availableAlertManagersNames.includes(alertManagerName); +} diff --git a/public/app/plugins/panel/alertGroups/AlertmanagerPicker.tsx b/public/app/plugins/panel/alertGroups/AlertmanagerPicker.tsx new file mode 100644 index 00000000000..2d18cd111a0 --- /dev/null +++ b/public/app/plugins/panel/alertGroups/AlertmanagerPicker.tsx @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { Select } from '@grafana/ui'; +import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; + +interface Props { + onChange: (alertManagerSourceName: string) => void; + current?: string; + dataSources: AlertManagerDataSource[]; +} + +function getAlertManagerLabel(alertManager: AlertManagerDataSource) { + return alertManager.name === GRAFANA_RULES_SOURCE_NAME ? 'Grafana' : alertManager.name.slice(0, 37); +} + +export const AlertManagerPicker = ({ onChange, current, dataSources }: Props) => { + const options: Array> = useMemo(() => { + return dataSources.map((ds) => ({ + label: getAlertManagerLabel(ds), + value: ds.name, + imgUrl: ds.imgUrl, + meta: ds.meta, + })); + }, [dataSources]); + + return ( +