diff --git a/.betterer.results b/.betterer.results index e006165060f..bc0c24edcd0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2228,10 +2228,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "Styles should be written using objects.", "4"] ], - "public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/ReceiverMetadataBadge.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], "public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 295f4d8e7cf..7e091a115e2 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -80,6 +80,7 @@ Some features are enabled by default. You can disable these feature by setting t | `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the `useCachingService` feature toggle is enabled and the datasource has caching and async query support enabled | | `splitScopes` | Support faster dashboard and folder search by splitting permission scopes into parts | | `reportingRetries` | Enables rendering retries for the reporting feature | +| `alertingContactPointsV2` | Show the new contacpoints list view | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | ## Experimental feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f471b2d9b9a..8d905e8bfc1 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -128,6 +128,7 @@ export interface FeatureToggles { lokiRunQueriesInParallel?: boolean; wargamesTesting?: boolean; alertingInsights?: boolean; + alertingContactPointsV2?: boolean; externalCorePlugins?: boolean; pluginsAPIMetrics?: boolean; httpSLOLevels?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 70fd828c1c8..d12d46d9dfe 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -773,6 +773,13 @@ var ( Owner: grafanaAlertingSquad, Expression: "true", // enabled by default }, + { + Name: "alertingContactPointsV2", + Description: "Show the new contacpoints list view", + FrontendOnly: true, + Stage: FeatureStagePublicPreview, + Owner: grafanaAlertingSquad, + }, { Name: "externalCorePlugins", Description: "Allow core plugins to be loaded as external", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 570056669d2..d94647febe3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -109,6 +109,7 @@ libraryPanelRBAC,experimental,@grafana/dashboards-squad,false,false,true,false lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false,false wargamesTesting,experimental,@grafana/hosted-grafana-team,false,false,false,false alertingInsights,GA,@grafana/alerting-squad,false,false,false,true +alertingContactPointsV2,preview,@grafana/alerting-squad,false,false,false,true externalCorePlugins,experimental,@grafana/plugins-platform-backend,false,false,false,false pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,false,true httpSLOLevels,experimental,@grafana/hosted-grafana-team,false,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 299df484a21..7cdc48e682a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -447,6 +447,10 @@ const ( // Show the new alerting insights landing page FlagAlertingInsights = "alertingInsights" + // FlagAlertingContactPointsV2 + // Show the new contacpoints list view + FlagAlertingContactPointsV2 = "alertingContactPointsV2" + // FlagExternalCorePlugins // Allow core plugins to be loaded as external FlagExternalCorePlugins = "externalCorePlugins" diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 5e67be4822d..4ae31262daa 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -187,6 +187,18 @@ const unifiedRoutes: RouteDescriptor[] = [ () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') ), }, + { + path: '/alerting/notifications/receivers/:id/edit', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + ]), + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + ), + }, { path: '/alerting/notifications/:type/:id/edit', roles: evaluateAccess([ diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index 4b00dc040ff..ccaa9128c96 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { Disable, Enable } from 'react-enable'; import { Route, Switch } from 'react-router-dom'; +import { config } from '@grafana/runtime'; import { withErrorBoundary } from '@grafana/ui'; const ContactPointsV1 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v1')); const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v2')); @@ -17,13 +17,14 @@ import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynami import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; -import { AlertingFeature } from './features'; + +const newContactPointsListView = config.featureToggles.alertingContactPointsV2 ?? false; // TODO add pagenav back in – that way we have correct breadcrumbs and page title const ContactPoints = (props: GrafanaRouteComponentProps): JSX.Element => ( - - {/* TODO do we want a "routes" component for each Alerting entity? */} + {/* TODO do we want a "routes" component for each Alerting entity? */} + {newContactPointsListView ? ( @@ -37,10 +38,9 @@ const ContactPoints = (props: GrafanaRouteComponentProps): JSX.Element => ( /> - - + ) : ( - + )} ); diff --git a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx b/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx index 7d1306ccfad..9be6b64f41a 100644 --- a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx +++ b/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx @@ -14,12 +14,12 @@ interface GrafanaAlertmanagerDeliveryWarningProps { export function GrafanaAlertmanagerDeliveryWarning({ currentAlertmanager }: GrafanaAlertmanagerDeliveryWarningProps) { const styles = useStyles2(getStyles); - - const { useGetAlertmanagerChoiceStatusQuery } = alertmanagerApi; - const { currentData: amChoiceStatus } = useGetAlertmanagerChoiceStatusQuery(); - const viewingInternalAM = currentAlertmanager === GRAFANA_RULES_SOURCE_NAME; + const { currentData: amChoiceStatus } = alertmanagerApi.endpoints.getAlertmanagerChoiceStatus.useQuery(undefined, { + skip: !viewingInternalAM, + }); + const interactsWithExternalAMs = amChoiceStatus?.alertmanagersChoice && [AlertmanagerChoice.External, AlertmanagerChoice.All].includes(amChoiceStatus?.alertmanagersChoice); diff --git a/public/app/features/alerting/unified/components/MoreButton.tsx b/public/app/features/alerting/unified/components/MoreButton.tsx new file mode 100644 index 00000000000..a3f824d6505 --- /dev/null +++ b/public/app/features/alerting/unified/components/MoreButton.tsx @@ -0,0 +1,24 @@ +import React, { forwardRef, Ref } from 'react'; + +import { Stack } from '@grafana/experimental'; +import { Button, ButtonProps, Icon } from '@grafana/ui'; + +const MoreButton = forwardRef(function MoreButton(props: ButtonProps, ref: Ref) { + return ( + + ); +}); + +export default MoreButton; diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx index 08261d21f91..87ba1ca634c 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx @@ -58,6 +58,72 @@ describe('ContactPoints', () => { expect(screen.getByText('grafana-default-email')).toBeInTheDocument(); expect(screen.getAllByTestId('contact-point')).toHaveLength(4); }); + + it('should call delete when clicked and not disabled', async () => { + const onDelete = jest.fn(); + + render(, { + wrapper, + }); + + const moreActions = screen.getByRole('button', { name: 'more-actions' }); + await userEvent.click(moreActions); + + const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); + await userEvent.click(deleteButton); + + expect(onDelete).toHaveBeenCalledWith('my-contact-point'); + }); + + it('should disable edit button', async () => { + render(, { + wrapper, + }); + + const moreActions = screen.getByRole('button', { name: 'more-actions' }); + expect(moreActions).not.toBeDisabled(); + + const editAction = screen.getByTestId('edit-action'); + expect(editAction).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should disable buttons when provisioned', async () => { + render(, { + wrapper, + }); + + expect(screen.getByText(/provisioned/i)).toBeInTheDocument(); + + const editAction = screen.queryByTestId('edit-action'); + expect(editAction).not.toBeInTheDocument(); + + const viewAction = screen.getByRole('link', { name: /view/i }); + expect(viewAction).toBeInTheDocument(); + + const moreActions = screen.getByRole('button', { name: 'more-actions' }); + expect(moreActions).not.toBeDisabled(); + await userEvent.click(moreActions); + + const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); + expect(deleteButton).toBeDisabled(); + }); + + it('should disable delete when contact point is linked to at least one notification policy', async () => { + render( + , + { + wrapper, + } + ); + + expect(screen.getByRole('link', { name: 'is used by 1 notification policy' })).toBeInTheDocument(); + + const moreActions = screen.getByRole('button', { name: 'more-actions' }); + await userEvent.click(moreActions); + + const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); + expect(deleteButton).toBeDisabled(); + }); }); describe('Mimir-flavored alertmanager', () => { @@ -98,71 +164,6 @@ describe('ContactPoints', () => { }); }); -describe('ContactPoint', () => { - it('should call delete when clicked and not disabled', async () => { - const onDelete = jest.fn(); - - render(, { - wrapper, - }); - - const moreActions = screen.getByRole('button', { name: 'more-actions' }); - await userEvent.click(moreActions); - - const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); - await userEvent.click(deleteButton); - - expect(onDelete).toHaveBeenCalledWith('my-contact-point'); - }); - - it('should disable edit button', async () => { - render(, { - wrapper, - }); - - const moreActions = screen.getByRole('button', { name: 'more-actions' }); - expect(moreActions).not.toBeDisabled(); - - const editAction = screen.getByTestId('edit-action'); - expect(editAction).toHaveAttribute('aria-disabled', 'true'); - }); - - it('should disable buttons when provisioned', async () => { - render(, { - wrapper, - }); - - expect(screen.getByText(/provisioned/i)).toBeInTheDocument(); - - const editAction = screen.queryByTestId('edit-action'); - expect(editAction).not.toBeInTheDocument(); - - const viewAction = screen.getByRole('link', { name: /view/i }); - expect(viewAction).toBeInTheDocument(); - - const moreActions = screen.getByRole('button', { name: 'more-actions' }); - expect(moreActions).not.toBeDisabled(); - await userEvent.click(moreActions); - - const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); - expect(deleteButton).toBeDisabled(); - }); - - it('should disable delete when contact point is linked to at least one notification policy', async () => { - render(, { - wrapper, - }); - - expect(screen.getByRole('link', { name: 'is used by 1 notification policy' })).toBeInTheDocument(); - - const moreActions = screen.getByRole('button', { name: 'more-actions' }); - await userEvent.click(moreActions); - - const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); - expect(deleteButton).toBeDisabled(); - }); -}); - const wrapper = ({ children }: PropsWithChildren) => ( {children} diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx index 0576fdffc92..0881c8222a4 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx @@ -1,15 +1,15 @@ import { css } from '@emotion/css'; import { SerializedError } from '@reduxjs/toolkit'; -import { groupBy, size, uniqueId, upperFirst } from 'lodash'; +import { groupBy, size, upperFirst } from 'lodash'; import pluralize from 'pluralize'; -import React, { ReactNode, useState } from 'react'; +import React, { Fragment, ReactNode, useCallback, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useToggle } from 'react-use'; import { dateTime, GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { Alert, - Button, Dropdown, Icon, LoadingPlaceholder, @@ -22,47 +22,65 @@ import { TabContent, Tab, Pagination, + Button, } from '@grafana/ui'; -import { contextSrv } from 'app/core/core'; import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap'; -import { isOrgAdmin } from 'app/features/plugins/admin/permissions'; import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting'; +import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { usePagination } from '../../hooks/usePagination'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { INTEGRATION_ICONS } from '../../types/contact-points'; -import { getNotificationsPermissions } from '../../utils/access-control'; -import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { createUrl } from '../../utils/url'; +import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; import { MetaText } from '../MetaText'; +import MoreButton from '../MoreButton'; import { ProvisioningBadge } from '../Provisioning'; import { Spacer } from '../Spacer'; import { Strong } from '../Strong'; +import { GrafanaReceiverExporter } from '../export/GrafanaReceiverExporter'; +import { GrafanaReceiversExporter } from '../export/GrafanaReceiversExporter'; import { GlobalConfigAlert } from '../receivers/ReceiversAndTemplatesView'; import { UnusedContactPointBadge } from '../receivers/ReceiversTable'; +import { ReceiverMetadataBadge } from '../receivers/grafanaAppReceivers/ReceiverMetadataBadge'; +import { ReceiverPluginMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata'; import { MessageTemplates } from './MessageTemplates'; import { useDeleteContactPointModal } from './Modals'; -import { RECEIVER_STATUS_KEY, useContactPointsWithStatus, useDeleteContactPoint } from './useContactPoints'; -import { ContactPointWithStatus, getReceiverDescription, isProvisioned, ReceiverConfigWithStatus } from './utils'; +import { + RECEIVER_META_KEY, + RECEIVER_PLUGIN_META_KEY, + RECEIVER_STATUS_KEY, + useContactPointsWithStatus, + useDeleteContactPoint, +} from './useContactPoints'; +import { ContactPointWithMetadata, getReceiverDescription, isProvisioned, ReceiverConfigWithMetadata } from './utils'; enum ActiveTab { ContactPoints, MessageTemplates, } -const DEFAULT_PAGE_SIZE = 25; +const DEFAULT_PAGE_SIZE = 10; const ContactPoints = () => { const { selectedAlertmanager } = useAlertmanager(); // TODO hook up to query params const [activeTab, setActiveTab] = useState(ActiveTab.ContactPoints); - let { isLoading, error, contactPoints } = useContactPointsWithStatus(selectedAlertmanager!); + let { isLoading, error, contactPoints } = useContactPointsWithStatus(); const { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!); + const [addContactPointSupported, addContactPointAllowed] = useAlertmanagerAbility( + AlertmanagerAction.CreateContactPoint + ); + const [exportContactPointsSupported, exportContactPointsAllowed] = useAlertmanagerAbility( + AlertmanagerAction.ExportContactPoint + ); const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading); + const [ExportDrawer, showExportDrawer] = useExportContactPoint(); const showingContactPoints = activeTab === ActiveTab.ContactPoints; const showingMessageTemplates = activeTab === ActiveTab.MessageTemplates; @@ -73,13 +91,11 @@ const ContactPoints = () => { } const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; - const isVanillaAlertmanager = isVanillaPrometheusAlertManagerDataSource(selectedAlertmanager!); - const permissions = getNotificationsPermissions(selectedAlertmanager!); - - const allowedToAddContactPoint = contextSrv.hasPermission(permissions.create); return ( <> + + { active={showingMessageTemplates} onChangeTab={() => setActiveTab(ActiveTab.MessageTemplates)} /> - - {showingContactPoints && ( - - Add contact point - - )} - {showingMessageTemplates && ( - - Add message template - - )} @@ -123,9 +122,34 @@ const ContactPoints = () => { ) : ( <> {/* TODO we can add some additional info here with a ToggleTip */} - - Define where notifications are sent, a contact point can contain multiple integrations. - + + + Define where notifications are sent, a contact point can contain multiple integrations. + + + + {addContactPointSupported && ( + + Add contact point + + )} + {exportContactPointsSupported && ( + + )} + + { {/* Message Templates tab */} {showingMessageTemplates && ( <> - - Create message templates to customize your notifications. - + + + Create message templates to customize your notifications. + + + + Add message template + + )} @@ -152,12 +182,13 @@ const ContactPoints = () => { {DeleteModal} + {ExportDrawer} ); }; interface ContactPointsListProps { - contactPoints: ContactPointWithStatus[]; + contactPoints: ContactPointWithMetadata[]; disabled?: boolean; onDelete: (name: string) => void; pageSize?: number; @@ -198,7 +229,7 @@ interface ContactPointProps { name: string; disabled?: boolean; provisioned?: boolean; - receivers: ReceiverConfigWithStatus[]; + receivers: ReceiverConfigWithMetadata[]; policies?: number; onDelete: (name: string) => void; } @@ -228,16 +259,21 @@ export const ContactPoint = ({ /> {showFullMetadata ? (
- {receivers?.map((receiver) => { + {receivers.map((receiver, index) => { const diagnostics = receiver[RECEIVER_STATUS_KEY]; + const metadata = receiver[RECEIVER_META_KEY]; const sendingResolved = !Boolean(receiver.disableResolveMessage); + const pluginMetadata = receiver[RECEIVER_PLUGIN_META_KEY]; + const key = metadata.name + index; return ( ); @@ -264,15 +300,55 @@ interface ContactPointHeaderProps { const ContactPointHeader = (props: ContactPointHeaderProps) => { const { name, disabled = false, provisioned = false, policies = 0, onDelete } = props; const styles = useStyles2(getStyles); - const { selectedAlertmanager } = useAlertmanager(); - const permissions = getNotificationsPermissions(selectedAlertmanager ?? ''); + + const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint); + const [editSupported, editAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); + const [deleteSupported, deleteAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); + + const [ExportDrawer, openExportDrawer] = useExportContactPoint(); const isReferencedByPolicies = policies > 0; - const isGranaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; + const canEdit = editSupported && editAllowed && !provisioned; + const canDelete = deleteSupported && deleteAllowed && !provisioned && policies === 0; - // we make a distinction here becase for "canExport" we show the menu item, if not we hide it - const canExport = isGranaManagedAlertmanager; - const allowedToExport = contextSrv.hasPermission(permissions.provisioning.read); + const menuActions: JSX.Element[] = []; + + if (exportSupported) { + menuActions.push( + + openExportDrawer(name)} + /> + + + ); + } + + if (deleteSupported) { + menuActions.push( + ( + + {children} + + )} + > + onDelete(name)} + /> + + ); + } return (
@@ -282,115 +358,70 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { {name} - {isReferencedByPolicies ? ( + {isReferencedByPolicies && ( is used by {policies} {pluralize('notification policy', policies)} - ) : ( - )} {provisioned && } + {!isReferencedByPolicies && } - {provisioned ? 'View' : 'Edit'} + {canEdit ? 'Edit' : 'View'} - {/* TODO probably want to split this off since there's lots of RBAC involved here */} - - {canExport && ( - <> - - - - )} - 0} - wrap={(children) => ( - - {children} - - )} - > - 0} - onClick={() => onDelete(name)} - /> - - - } - > -
); }; interface ContactPointReceiverProps { + name: string; type: GrafanaNotifierType | string; description?: ReactNode; sendingResolved?: boolean; diagnostics?: NotifierStatus; + pluginMetadata?: ReceiverPluginMetadata; } const ContactPointReceiver = (props: ContactPointReceiverProps) => { - const { type, description, diagnostics, sendingResolved = true } = props; + const { name, type, description, diagnostics, pluginMetadata, sendingResolved = true } = props; const styles = useStyles2(getStyles); const iconName = INTEGRATION_ICONS[type]; const hasMetadata = diagnostics !== undefined; - // TODO get the actual name of the type from /ngalert if grafanaManaged AM - const receiverName = receiverTypeNames[type] ?? upperFirst(type); - return (
{iconName && } - - {receiverName} - + {pluginMetadata ? ( + + ) : ( + + {name} + + )} {description && ( @@ -502,6 +533,44 @@ const ContactPointReceiverMetadataRow = ({ diagnostics, sendingResolved }: Conta ); }; +const ALL_CONTACT_POINTS = Symbol('all contact points'); + +type ExportProps = [JSX.Element | null, (receiver: string | typeof ALL_CONTACT_POINTS) => void]; + +const useExportContactPoint = (): ExportProps => { + const [receiverName, setReceiverName] = useState(null); + const [isExportDrawerOpen, toggleShowExportDrawer] = useToggle(false); + const [decryptSecretsSupported, decryptSecretsAllowed] = useAlertmanagerAbility(AlertmanagerAction.DecryptSecrets); + + const canReadSecrets = decryptSecretsSupported && decryptSecretsAllowed; + + const handleClose = useCallback(() => { + setReceiverName(null); + toggleShowExportDrawer(false); + }, [toggleShowExportDrawer]); + + const handleOpen = (receiverName: string | typeof ALL_CONTACT_POINTS) => { + setReceiverName(receiverName); + toggleShowExportDrawer(true); + }; + + const drawer = useMemo(() => { + if (!receiverName || !isExportDrawerOpen) { + return null; + } + + if (receiverName === ALL_CONTACT_POINTS) { + // use this drawer when we want to export all contact points + return ; + } else { + // use this one for exporting a single contact point + return ; + } + }, [canReadSecrets, isExportDrawerOpen, handleClose, receiverName]); + + return [drawer, handleOpen]; +}; + const getStyles = (theme: GrafanaTheme2) => ({ contactPointWrapper: css({ borderRadius: `${theme.shape.radius.default}`, diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts index ba281758e43..72116c2598f 100644 --- a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts @@ -1,9 +1,11 @@ import { rest } from 'msw'; -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { AlertmanagerChoice, AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { ReceiversStateDTO } from 'app/types'; -import { setupMswServer } from '../../../mockApi'; +import { mockApi, setupMswServer } from '../../../mockApi'; +import { mockAlertmanagerChoiceResponse } from '../../../mocks/alertmanagerApi'; +import { grafanaNotifiersMock } from '../../../mocks/grafana-notifiers'; import alertmanagerMock from './alertmanager.config.mock.json'; import receiversMock from './receivers.mock.json'; @@ -19,6 +21,19 @@ export default () => { // this endpoint is only available for the built-in alertmanager rest.get('/api/alertmanager/grafana/config/api/v1/receivers', (_req, res, ctx) => res(ctx.json(receiversMock)) - ) + ), + // this endpoint will respond if the OnCall plugin is installed + rest.get('/api/plugins/grafana-oncall-app/settings', (_req, res, ctx) => res(ctx.status(404))) ); + + // this endpoint is for rendering the "additional AMs to configure" warning + mockAlertmanagerChoiceResponse(server, { + alertmanagersChoice: AlertmanagerChoice.Internal, + numExternalAlertmanagers: 1, + }); + + // mock the endpoint for contact point metadata + mockApi(server).grafanaNotifiers(grafanaNotifiersMock); + + return server; }; diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts index b5d2ec38274..f27d795cf6c 100644 --- a/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts +++ b/public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts @@ -18,6 +18,8 @@ export default () => { ), rest.get(`/api/datasources/proxy/uid/${MIMIR_DATASOURCE_UID}/api/v1/status/buildinfo`, (_req, res, ctx) => res(ctx.status(404)) - ) + ), + // this endpoint will respond if the OnCall plugin is installed + rest.get('/api/plugins/grafana-oncall-app/settings', (_req, res, ctx) => res(ctx.status(404))) ); }; diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap index e82347ce7e6..5d26e30334e 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -22,6 +22,11 @@ exports[`useContactPoints should return contact points with status 1`] = ` "name": "email", "sendResolved": true, }, + Symbol(receiver_metadata): { + "description": "Sends notifications using Grafana server configured SMTP settings", + "name": "Email", + }, + Symbol(receiver_plugin_metadata): undefined, }, ], "name": "grafana-default-email", @@ -46,6 +51,11 @@ exports[`useContactPoints should return contact points with status 1`] = ` "name": "email", "sendResolved": true, }, + Symbol(receiver_metadata): { + "description": "Sends notifications using Grafana server configured SMTP settings", + "name": "Email", + }, + Symbol(receiver_plugin_metadata): undefined, }, ], "name": "provisioned-contact-point", @@ -69,6 +79,11 @@ exports[`useContactPoints should return contact points with status 1`] = ` "name": "email", "sendResolved": true, }, + Symbol(receiver_metadata): { + "description": "Sends notifications using Grafana server configured SMTP settings", + "name": "Email", + }, + Symbol(receiver_plugin_metadata): undefined, }, ], "name": "lotsa-emails", @@ -93,6 +108,11 @@ exports[`useContactPoints should return contact points with status 1`] = ` "name": "slack", "sendResolved": true, }, + Symbol(receiver_metadata): { + "description": "Sends notifications to Slack", + "name": "Slack", + }, + Symbol(receiver_plugin_metadata): undefined, }, { "disableResolveMessage": false, @@ -111,6 +131,11 @@ exports[`useContactPoints should return contact points with status 1`] = ` "name": "slack", "sendResolved": true, }, + Symbol(receiver_metadata): { + "description": "Sends notifications to Slack", + "name": "Slack", + }, + Symbol(receiver_plugin_metadata): undefined, }, ], "name": "Slack with multiple channels", diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx index c96d03ae9b9..b5c12d7988f 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -1,15 +1,31 @@ import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; +import { AccessControlAction } from 'app/types'; + +import { grantUserPermissions } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; + import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer'; import { useContactPointsWithStatus } from './useContactPoints'; describe('useContactPoints', () => { setupGrafanaManagedServer(); + beforeAll(() => { + grantUserPermissions([AccessControlAction.AlertingNotificationsRead]); + }); + it('should return contact points with status', async () => { - const { result } = renderHook(() => useContactPointsWithStatus('grafana'), { - wrapper: TestProvider, + const { result } = renderHook(() => useContactPointsWithStatus(), { + wrapper: ({ children }) => ( + + + {children} + + + ), }); await waitFor(() => { diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx index 095c586afec..555341a9f76 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx @@ -7,48 +7,81 @@ import { produce } from 'immer'; import { remove } from 'lodash'; import { alertmanagerApi } from '../../api/alertmanagerApi'; -import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { onCallApi } from '../../api/onCallApi'; +import { usePluginBridge } from '../../hooks/usePluginBridge'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { SupportedPlugin } from '../../types/pluginBridges'; -import { enhanceContactPointsWithStatus } from './utils'; +import { enhanceContactPointsWithMetadata } from './utils'; export const RECEIVER_STATUS_KEY = Symbol('receiver_status'); +export const RECEIVER_META_KEY = Symbol('receiver_metadata'); +export const RECEIVER_PLUGIN_META_KEY = Symbol('receiver_plugin_metadata'); + const RECEIVER_STATUS_POLLING_INTERVAL = 10 * 1000; // 10 seconds /** - * This hook will combine data from two endpoints; + * This hook will combine data from several endpoints; * 1. the alertmanager config endpoint where the definition of the receivers are * 2. (if available) the alertmanager receiver status endpoint, currently Grafana Managed only + * 3. (if available) additional metadata about Grafana Managed contact points + * 4. (if available) the OnCall plugin metadata */ -export function useContactPointsWithStatus(selectedAlertmanager: string) { - const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; +export function useContactPointsWithStatus() { + const { selectedAlertmanager, isGrafanaAlertmanager } = useAlertmanager(); + const { installed: onCallPluginInstalled = false, loading: onCallPluginStatusLoading } = usePluginBridge( + SupportedPlugin.OnCall + ); // fetch receiver status if we're dealing with a Grafana Managed Alertmanager const fetchContactPointsStatus = alertmanagerApi.endpoints.getContactPointsStatus.useQuery(undefined, { - // TODO these don't seem to work since we've not called setupListeners() refetchOnFocus: true, refetchOnReconnect: true, // re-fetch status every so often for up-to-date information pollingInterval: RECEIVER_STATUS_POLLING_INTERVAL, // skip fetching receiver statuses if not Grafana AM - skip: !isGrafanaManagedAlertmanager, + skip: !isGrafanaAlertmanager, }); + // fetch notifier metadata from the Grafana API if we're using a Grafana AM – this will be used to add additional + // metadata and canonical names to the receiver + const fetchReceiverMetadata = alertmanagerApi.endpoints.grafanaNotifiers.useQuery(undefined, { + skip: !isGrafanaAlertmanager, + }); + + // if the OnCall plugin is installed, fetch its list of integrations so we can match those to the Grafana Managed contact points + const { data: onCallIntegrations, isLoading: onCallPluginIntegrationsLoading } = + onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(undefined, { + skip: !onCallPluginInstalled || !isGrafanaAlertmanager, + }); + // fetch the latest config from the Alertmanager const fetchAlertmanagerConfiguration = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useQuery( - selectedAlertmanager, + selectedAlertmanager!, { refetchOnFocus: true, refetchOnReconnect: true, selectFromResult: (result) => ({ ...result, - contactPoints: result.data ? enhanceContactPointsWithStatus(result.data, fetchContactPointsStatus.data) : [], + contactPoints: result.data + ? enhanceContactPointsWithMetadata( + result.data, + fetchContactPointsStatus.data, + fetchReceiverMetadata.data, + onCallPluginInstalled ? onCallIntegrations ?? [] : null + ) + : [], }), } ); - // TODO kinda yucky to combine hooks like this, better alternative? + // we will fail silently for fetching OnCall plugin status and integrations const error = fetchAlertmanagerConfiguration.error ?? fetchContactPointsStatus.error; - const isLoading = fetchAlertmanagerConfiguration.isLoading || fetchContactPointsStatus.isLoading; + const isLoading = + fetchAlertmanagerConfiguration.isLoading || + fetchContactPointsStatus.isLoading || + onCallPluginStatusLoading || + onCallPluginIntegrationsLoading; const contactPoints = fetchAlertmanagerConfiguration.contactPoints; diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index bcec11e595a..414d7bbc00c 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -1,4 +1,4 @@ -import { countBy, split, trim } from 'lodash'; +import { countBy, split, trim, upperFirst } from 'lodash'; import { ReactNode } from 'react'; import { @@ -7,12 +7,15 @@ import { GrafanaManagedReceiverConfig, Route, } from 'app/plugins/datasource/alertmanager/types'; -import { NotifierStatus, ReceiversStateDTO } from 'app/types'; +import { NotifierDTO, NotifierStatus, ReceiversStateDTO } from 'app/types'; +import { OnCallIntegrationDTO } from '../../api/onCallApi'; import { computeInheritedTree } from '../../utils/notification-policies'; import { extractReceivers } from '../../utils/receivers'; +import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; +import { getOnCallMetadata, ReceiverPluginMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata'; -import { RECEIVER_STATUS_KEY } from './useContactPoints'; +import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY, RECEIVER_STATUS_KEY } from './useContactPoints'; export function isProvisioned(contactPoint: GrafanaManagedContactPoint) { // for some reason the provenance is on the receiver and not the entire contact point @@ -22,7 +25,7 @@ export function isProvisioned(contactPoint: GrafanaManagedContactPoint) { } // TODO we should really add some type information to these receiver settings... -export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig): ReactNode | undefined { +export function getReceiverDescription(receiver: ReceiverConfigWithMetadata): ReactNode | undefined { switch (receiver.type) { case 'email': { const hasEmailAddresses = 'addresses' in receiver.settings; // when dealing with alertmanager email_configs we don't normalize the settings @@ -40,8 +43,11 @@ export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig): const url = receiver.settings['url']; return url; } + case ReceiverTypes.OnCall: { + return receiver[RECEIVER_PLUGIN_META_KEY]?.description; + } default: - return undefined; + return receiver[RECEIVER_META_KEY]?.description; } } @@ -64,15 +70,21 @@ function summarizeEmailAddresses(addresses: string): string { } // Grafana Managed contact points have receivers with additional diagnostics -export interface ReceiverConfigWithStatus extends GrafanaManagedReceiverConfig { +export interface ReceiverConfigWithMetadata extends GrafanaManagedReceiverConfig { // we're using a symbol here so we'll never have a conflict on keys for a receiver // we also specify that the diagnostics might be "undefined" for vanilla Alertmanager [RECEIVER_STATUS_KEY]?: NotifierStatus | undefined; + [RECEIVER_META_KEY]: { + name: string; + description?: string; + }; + // optional metadata that comes from a particular plugin (like Grafana OnCall) + [RECEIVER_PLUGIN_META_KEY]?: ReceiverPluginMetadata; } -export interface ContactPointWithStatus extends GrafanaManagedContactPoint { +export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { numberOfPolicies: number; - grafana_managed_receiver_configs: ReceiverConfigWithStatus[]; + grafana_managed_receiver_configs: ReceiverConfigWithMetadata[]; } /** @@ -80,10 +92,12 @@ export interface ContactPointWithStatus extends GrafanaManagedContactPoint { * 1. we iterate over all contact points * 2. for each contact point we "enhance" it with the status or "undefined" for vanilla Alertmanager */ -export function enhanceContactPointsWithStatus( +export function enhanceContactPointsWithMetadata( result: AlertManagerCortexConfig, - status: ReceiversStateDTO[] = [] -): ContactPointWithStatus[] { + status: ReceiversStateDTO[] = [], + notifiers: NotifierDTO[] = [], + onCallIntegrations: OnCallIntegrationDTO[] | null +): ContactPointWithMetadata[] { const contactPoints = result.alertmanager_config.receivers ?? []; // compute the entire inherited tree before finding what notification policies are using a particular contact point @@ -98,10 +112,17 @@ export function enhanceContactPointsWithStatus( return { ...contactPoint, numberOfPolicies: usedContactPointsByName[contactPoint.name] ?? 0, - grafana_managed_receiver_configs: receivers.map((receiver, index) => ({ - ...receiver, - [RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index], - })), + grafana_managed_receiver_configs: receivers.map((receiver, index) => { + const isOnCallReceiver = receiver.type === ReceiverTypes.OnCall; + + return { + ...receiver, + [RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index], + [RECEIVER_META_KEY]: getNotifierMetadata(notifiers, receiver), + // if OnCall plugin is installed, we'll add it to the receiver's plugin metadata + [RECEIVER_PLUGIN_META_KEY]: isOnCallReceiver ? getOnCallMetadata(onCallIntegrations, receiver) : undefined, + }; + }), }; }); } @@ -114,3 +135,12 @@ export function getUsedContactPoints(route: Route): string[] { return childrenContactPoints; } + +function getNotifierMetadata(notifiers: NotifierDTO[], receiver: GrafanaManagedReceiverConfig) { + const match = notifiers.find((notifier) => notifier.type === receiver.type); + + return { + name: match?.name ?? upperFirst(receiver.type), + description: match?.description, + }; +} diff --git a/public/app/features/alerting/unified/components/export/FileExportPreview.tsx b/public/app/features/alerting/unified/components/export/FileExportPreview.tsx index f9729cc4a3a..2a7f5c06ee6 100644 --- a/public/app/features/alerting/unified/components/export/FileExportPreview.tsx +++ b/public/app/features/alerting/unified/components/export/FileExportPreview.tsx @@ -25,9 +25,7 @@ export function FileExportPreview({ format, textDefinition, downloadFileName, on type: `application/${format};charset=utf-8`, }); saveAs(blob, `${downloadFileName}.${format}`); - - onClose(); - }, [textDefinition, downloadFileName, format, onClose]); + }, [textDefinition, downloadFileName, format]); const formattedTextDefinition = useMemo(() => { const provider = allGrafanaExportProviders[format]; @@ -49,6 +47,7 @@ export function FileExportPreview({ format, textDefinition, downloadFileName, on minimap: { enabled: false, }, + scrollBeyondLastLine: false, lineNumbers: 'on', readOnly: true, }} diff --git a/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx new file mode 100644 index 00000000000..0a543be22b3 --- /dev/null +++ b/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { Stack } from '@grafana/experimental'; +import { Select, SelectCommonProps, Text } from '@grafana/ui'; + +import { + RECEIVER_META_KEY, + RECEIVER_PLUGIN_META_KEY, + useContactPointsWithStatus, +} from '../contact-points/useContactPoints'; +import { ReceiverConfigWithMetadata } from '../contact-points/utils'; + +export const ContactPointSelector = (props: SelectCommonProps) => { + const { contactPoints, isLoading, error } = useContactPointsWithStatus(); + + // TODO error handling + if (error) { + return Failed to load contact points; + } + + const options: Array> = contactPoints.map((contactPoint) => { + return { + label: contactPoint.name, + value: contactPoint.name, + component: () => , + }; + }); + + return