From e219e2a8342603d525c0e5010bf0f95b0c23c857 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed, 21 Dec 2022 14:46:55 +0100 Subject: [PATCH] Alerting: Recognise & change UI for OnCall notification policy + contact point (#60259) * Identify and show onCall contact points with a badge in case the plugin is installed * Add onCall logo for onCall contact points Badge * Refactor and make Grafana App Receiver type more generic, not only for onCall type * Show onCall notification policy in the specific routing table with special onCall badge * Fix tests * Move onCall badge to the type column in contact points view table * Fix typos and remove onCallIntegrations from tagTypes in alertingApi * Fetch only local plugins instead of all (external are not needed) and don't fetch plugin details * Use directly useGetOnCallIntegrationsQuery and more PR review suggestions * Move onCall contact point to the top in the drop-down, in the notification policy view * Add PR review requested changes --- .betterer.results | 3 - .../features/alerting/unified/AmRoutes.tsx | 11 ++-- .../alerting/unified/Receivers.test.tsx | 4 ++ .../alerting/unified/api/alertingApi.ts | 2 +- .../alerting/unified/api/onCallApi.ts | 20 +++++++ .../components/amroutes/AmRootRoute.tsx | 3 +- .../components/amroutes/AmRootRouteForm.tsx | 3 +- .../amroutes/AmRoutesExpandedForm.tsx | 13 ++++- .../amroutes/AmRoutesExpandedRead.tsx | 3 +- .../components/amroutes/AmRoutesTable.tsx | 19 ++++++- .../components/amroutes/AmSpecificRouting.tsx | 3 +- .../receivers/ReceiversTable.test.tsx | 5 ++ .../components/receivers/ReceiversTable.tsx | 45 ++++++++++----- .../grafanaAppReceivers/GrafanaAppBadge.tsx | 32 +++++++++++ .../grafanaAppReceivers/grafanaApp.ts | 56 +++++++++++++++++++ .../grafanaAppReceivers/onCall/onCall.ts | 19 +++++++ .../receivers/grafanaAppReceivers/types.ts | 23 ++++++++ .../alerting/unified/types/amroutes.ts | 5 -- .../features/plugins/admin/state/actions.ts | 13 ++++- .../app/features/plugins/admin/state/hooks.ts | 17 +++++- .../features/plugins/admin/state/reducer.ts | 14 ++++- public/img/alerting/oncall_logo.svg | 9 +++ 22 files changed, 282 insertions(+), 40 deletions(-) create mode 100644 public/app/features/alerting/unified/api/onCallApi.ts create mode 100644 public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/GrafanaAppBadge.tsx create mode 100644 public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/grafanaApp.ts create mode 100644 public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/onCall.ts create mode 100644 public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/types.ts create mode 100644 public/img/alerting/oncall_logo.svg diff --git a/.betterer.results b/.betterer.results index 242627a475f..50d5081fa62 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2799,9 +2799,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/AmRoutes.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/alerting/unified/AmRoutes.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/alerting/unified/PanelAlertTabContent.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx index b69a685e23a..436ffa0276d 100644 --- a/public/app/features/alerting/unified/AmRoutes.tsx +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui'; -import { Receiver } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; import { useCleanup } from '../../../core/hooks/useCleanup'; @@ -17,12 +16,14 @@ import { ProvisionedResource, ProvisioningAlert } from './components/Provisionin import { AmRootRoute } from './components/amroutes/AmRootRoute'; import { AmSpecificRouting } from './components/amroutes/AmSpecificRouting'; import { MuteTimingsTable } from './components/amroutes/MuteTimingsTable'; +import { useGetAmRouteReceiverWithGrafanaAppTypes } from './components/receivers/grafanaAppReceivers/grafanaApp'; +import { AmRouteReceiver } from './components/receivers/grafanaAppReceivers/types'; import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; import { useAlertManagersByPermission } from './hooks/useAlertManagerSources'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; import { fetchAlertManagerConfigAction, updateAlertManagerConfigAction } from './state/actions'; -import { AmRouteReceiver, FormAmRoute } from './types/amroutes'; -import { amRouteToFormAmRoute, formAmRouteToAmRoute, stringsToSelectableValues } from './utils/amroutes'; +import { FormAmRoute } from './types/amroutes'; +import { amRouteToFormAmRoute, formAmRouteToAmRoute } from './utils/amroutes'; import { isVanillaPrometheusAlertManagerDataSource } from './utils/datasource'; import { initialAsyncRequestState } from './utils/redux'; @@ -56,9 +57,7 @@ const AmRoutes = () => { const config = result?.alertmanager_config; const [rootRoute, id2ExistingRoute] = useMemo(() => amRouteToFormAmRoute(config?.route), [config?.route]); - const receivers = stringsToSelectableValues( - (config?.receivers ?? []).map((receiver: Receiver) => receiver.name) - ) as AmRouteReceiver[]; + const receivers: AmRouteReceiver[] = useGetAmRouteReceiverWithGrafanaAppTypes(config?.receivers ?? []); const isProvisioned = useMemo(() => Boolean(config?.route?.provenance), [config?.route]); diff --git a/public/app/features/alerting/unified/Receivers.test.tsx b/public/app/features/alerting/unified/Receivers.test.tsx index 9e9c806b444..ed5c53b7357 100644 --- a/public/app/features/alerting/unified/Receivers.test.tsx +++ b/public/app/features/alerting/unified/Receivers.test.tsx @@ -27,6 +27,7 @@ import { fetchAlertManagerConfig, fetchStatus, testReceivers, updateAlertManager import { discoverAlertmanagerFeatures } from './api/buildInfo'; import { fetchNotifiers } from './api/grafana'; import * as receiversApi from './api/receiversApi'; +import * as grafanaApp from './components/receivers/grafanaAppReceivers/grafanaApp'; import { mockDataSource, MockDataSourceSrv, @@ -143,6 +144,8 @@ const clickSelectOption = async (selectElement: HTMLElement, optionText: string) document.addEventListener('click', interceptLinkClicks); const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount: 0 }; +const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker'); + describe('Receivers', () => { const server = setupServer(); @@ -158,6 +161,7 @@ describe('Receivers', () => { beforeEach(() => { server.resetHandlers(); jest.resetAllMocks(); + useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock); mocks.api.discoverAlertmanagerFeatures.mockResolvedValue({ lazyConfigInit: false }); diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index ac0acd4d837..3c3cc75fbbc 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -5,7 +5,7 @@ import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; import { logInfo } from '../Analytics'; -const backendSrvBaseQuery = (): BaseQueryFn => async (requestOptions) => { +export const backendSrvBaseQuery = (): BaseQueryFn => async (requestOptions) => { try { const requestStartTs = performance.now(); diff --git a/public/app/features/alerting/unified/api/onCallApi.ts b/public/app/features/alerting/unified/api/onCallApi.ts new file mode 100644 index 00000000000..87cc97b33d0 --- /dev/null +++ b/public/app/features/alerting/unified/api/onCallApi.ts @@ -0,0 +1,20 @@ +import { alertingApi } from './alertingApi'; +export interface OnCallIntegration { + integration_url: string; +} +export type OnCallIntegrationsResponse = OnCallIntegration[]; +export type OnCallIntegrationsUrls = string[]; + +export const onCallApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + getOnCallIntegrations: build.query({ + query: () => ({ + headers: {}, + url: '/api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels/', + }), + providesTags: ['AlertmanagerChoice'], + transformResponse: (response: OnCallIntegrationsResponse) => response.map((result) => result.integration_url), + }), + }), +}); +export const { useGetOnCallIntegrationsQuery } = onCallApi; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx b/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx index a88c517e647..5c7c56d8445 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx @@ -5,8 +5,9 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Button, useStyles2 } from '@grafana/ui'; import { Authorize } from '../../components/Authorize'; -import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; +import { FormAmRoute } from '../../types/amroutes'; import { getNotificationsPermissions } from '../../utils/access-control'; +import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types'; import { AmRootRouteForm } from './AmRootRouteForm'; import { AmRootRouteRead } from './AmRootRouteRead'; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRootRouteForm.tsx b/public/app/features/alerting/unified/components/amroutes/AmRootRouteForm.tsx index 52c700e7248..6768061c0cb 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRootRouteForm.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRootRouteForm.tsx @@ -3,7 +3,7 @@ import React, { FC, useState } from 'react'; import { Button, Collapse, Field, Form, Input, InputControl, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui'; -import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; +import { FormAmRoute } from '../../types/amroutes'; import { mapMultiSelectValueToStrings, mapSelectValueToString, @@ -14,6 +14,7 @@ import { } from '../../utils/amroutes'; import { makeAMLink } from '../../utils/misc'; import { timeOptions } from '../../utils/time'; +import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types'; import { getFormStyles } from './formStyles'; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx index ca979ae5243..bc5a76f1ce1 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx @@ -20,7 +20,7 @@ import { } from '@grafana/ui'; import { useMuteTimingOptions } from '../../hooks/useMuteTimingOptions'; -import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; +import { FormAmRoute } from '../../types/amroutes'; import { matcherFieldOptions } from '../../utils/alertmanager'; import { emptyArrayFieldMatcher, @@ -32,6 +32,7 @@ import { commonGroupByOptions, } from '../../utils/amroutes'; import { timeOptions } from '../../utils/time'; +import { AmRouteReceiver, GrafanaAppReceiverEnum } from '../receivers/grafanaAppReceivers/types'; import { getFormStyles } from './formStyles'; @@ -48,6 +49,14 @@ export const AmRoutesExpandedForm: FC = ({ onCancel, const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(routes.groupBy)); const muteTimingOptions = useMuteTimingOptions(); + const receiversWithOnCallOnTop = receivers.sort((receiver1, receiver2) => { + if (receiver1.grafanaAppReceiverType === GrafanaAppReceiverEnum.GRAFANA_ONCALL) { + return -1; + } else { + return 0; + } + }); + return (
{({ control, register, errors, setValue, watch }) => ( @@ -148,7 +157,7 @@ export const AmRoutesExpandedForm: FC = ({ onCancel, {...field} className={formStyles.input} onChange={(value) => onChange(mapSelectValueToString(value))} - options={receivers} + options={receiversWithOnCallOnTop} /> )} control={control} diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedRead.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedRead.tsx index 8cfa0270a1c..b5077ef0e16 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedRead.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedRead.tsx @@ -4,10 +4,11 @@ import React, { FC, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, useStyles2 } from '@grafana/ui'; -import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; +import { FormAmRoute } from '../../types/amroutes'; import { getNotificationsPermissions } from '../../utils/access-control'; import { emptyRoute } from '../../utils/amroutes'; import { Authorize } from '../Authorize'; +import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types'; import { AmRoutesTable } from './AmRoutesTable'; import { MuteTimingsTable } from './MuteTimingsTable'; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx index 741d4f4a266..9f34dbffd26 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx @@ -4,12 +4,14 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Button, ConfirmModal, HorizontalGroup, IconButton } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; -import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; +import { FormAmRoute } from '../../types/amroutes'; import { getNotificationsPermissions } from '../../utils/access-control'; import { matcherFieldToMatcher, parseMatchers } from '../../utils/alertmanager'; import { prepareItems } from '../../utils/dynamicTable'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { EmptyArea } from '../EmptyArea'; +import { GrafanaAppBadge } from '../receivers/grafanaAppReceivers/GrafanaAppBadge'; +import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types'; import { Matchers } from '../silences/Matchers'; import { AmRoutesExpandedForm } from './AmRoutesExpandedForm'; @@ -67,6 +69,10 @@ export const deleteRoute = (routes: FormAmRoute[], routeId: string): FormAmRoute return routes.filter((route) => route.id !== routeId); }; +export const getGrafanaAppReceiverType = (receivers: AmRouteReceiver[], receiverName: string) => { + return receivers.find((receiver) => receiver.label === receiverName)?.grafanaAppReceiverType; +}; + export const AmRoutesTable: FC = ({ isAddMode, onCancelAdd, @@ -112,7 +118,16 @@ export const AmRoutesTable: FC = ({ { id: 'receiverChannel', label: 'Contact point', - renderCell: (item) => item.data.receiver || '-', + renderCell: (item) => { + const type = getGrafanaAppReceiverType(receivers, item.data.receiver); + return item.data.receiver ? ( + <> + {item.data.receiver} {type && } + + ) : ( + '-' + ); + }, size: 5, }, { diff --git a/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx b/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx index bfee8e38bc6..a7348468089 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx @@ -8,13 +8,14 @@ import { contextSrv } from 'app/core/services/context_srv'; import { Authorize } from '../../components/Authorize'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; -import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; +import { FormAmRoute } from '../../types/amroutes'; import { getNotificationsPermissions } from '../../utils/access-control'; import { emptyArrayFieldMatcher, emptyRoute } from '../../utils/amroutes'; import { getNotificationPoliciesFilters } from '../../utils/misc'; import { EmptyArea } from '../EmptyArea'; import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA'; import { MatcherFilter } from '../alert-groups/MatcherFilter'; +import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types'; import { AmRoutesTable } from './AmRoutesTable'; diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx index 8d89019f5b3..6ade4ead637 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx @@ -12,10 +12,12 @@ import { import { configureStore } from 'app/store/configureStore'; import { ContactPointsState, NotifierDTO, NotifierType } from 'app/types'; +import * as onCallApi from '../../api/onCallApi'; import * as receiversApi from '../../api/receiversApi'; import { fetchGrafanaNotifiersAction } from '../../state/actions'; import { ReceiversTable } from './ReceiversTable'; +import * as grafanaApp from './grafanaAppReceivers/grafanaApp'; const renderReceieversTable = async (receivers: Receiver[], notifiers: NotifierDTO[]) => { const config: AlertManagerCortexConfig = { @@ -53,6 +55,8 @@ const mockNotifier = (type: NotifierType, name: string): NotifierDTO => ({ options: [], }); +jest.spyOn(onCallApi, 'useGetOnCallIntegrationsQuery'); +const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker'); const useGetContactPointsStateMock = jest.spyOn(receiversApi, 'useGetContactPointsState'); describe('ReceiversTable', () => { @@ -60,6 +64,7 @@ describe('ReceiversTable', () => { jest.resetAllMocks(); const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount: 0 }; useGetContactPointsStateMock.mockReturnValue(emptyContactPointsState); + useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined); }); it('render receivers with grafana notifiers', async () => { diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx index d274fe7c4b5..301f4e53edd 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -6,7 +6,7 @@ import { GrafanaTheme2, dateTime, dateTimeFormat } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { Button, ConfirmModal, Modal, useStyles2, Badge, Icon } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; -import { AlertManagerCortexConfig, Receiver } from 'app/plugins/datasource/alertmanager/types'; +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch, AccessControlAction, ContactPointsState, NotifiersState, ReceiversState } from 'app/types'; import { useGetContactPointsState } from '../../api/receiversApi'; @@ -24,6 +24,9 @@ import { ProvisioningBadge } from '../Provisioning'; import { ActionIcon } from '../rules/ActionIcon'; import { ReceiversSection } from './ReceiversSection'; +import { GrafanaAppBadge } from './grafanaAppReceivers/GrafanaAppBadge'; +import { useGetReceiversWithGrafanaAppTypes } from './grafanaAppReceivers/grafanaApp'; +import { GrafanaAppReceiverEnum, ReceiverWithTypes } from './grafanaAppReceivers/types'; interface UpdateActionProps extends ActionProps { onClickDeleteReceiver: (receiverName: string) => void; @@ -128,6 +131,7 @@ interface ReceiverItem { name: string; types: string[]; provisioned?: boolean; + grafanaAppReceiverType?: GrafanaAppReceiverEnum; } interface NotifierStatus { @@ -261,10 +265,10 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => { } setReceiverToDelete(undefined); }; - - const rows: RowItemTableProps[] = useMemo( - () => - config.alertmanager_config.receivers?.map((receiver: Receiver) => ({ + const receivers = useGetReceiversWithGrafanaAppTypes(config.alertmanager_config.receivers ?? []); + const rows: RowItemTableProps[] = useMemo(() => { + return ( + receivers?.map((receiver: ReceiverWithTypes) => ({ id: receiver.name, data: { name: receiver.name, @@ -276,11 +280,13 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => { return type; } ), + grafanaAppReceiverType: receiver.grafanaAppReceiverType, provisioned: receiver.grafana_managed_receiver_configs?.some((receiver) => receiver.provenance), }, - })) ?? [], - [config, grafanaNotifiers.result] - ); + })) ?? [] + ); + }, [grafanaNotifiers.result, receivers]); + const columns = useGetColumns( alertManagerName, errorStateAvailable, @@ -296,7 +302,7 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => { title="Contact points" description="Define where the notifications will be sent to, for example email or Slack." showButton={!isVanillaAM && contextSrv.hasPermission(permissions.create)} - addButtonLabel="New contact point" + addButtonLabel={'New contact point'} addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)} > ( - <> - {name} {provisioned && } - + +
{name}
+ {provisioned && } +
), size: 1, }, { id: 'type', label: 'Type', - renderCell: ({ data: { types } }) => <>{types.join(', ')}, + renderCell: ({ data: { types, grafanaAppReceiverType } }) => ( + <>{grafanaAppReceiverType ? : types.join(', ')} + ), size: 1, }, ]; @@ -435,4 +444,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ color: ${theme.colors.warning.text}; `, countMessage: css``, + onCallBadgeWrapper: css` + text-align: left; + height: 22px; + display: inline-flex; + padding: 1px 4px; + border-radius: 3px; + border: 1px solid rgba(245, 95, 62, 1); + color: rgba(245, 95, 62, 1); + font-weight: ${theme.typography.fontWeightRegular}; + `, }); diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/GrafanaAppBadge.tsx b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/GrafanaAppBadge.tsx new file mode 100644 index 00000000000..30c36d99cf8 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/GrafanaAppBadge.tsx @@ -0,0 +1,32 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { HorizontalGroup, useStyles2 } from '@grafana/ui'; + +import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE, GrafanaAppReceiverEnum } from './types'; + +export const GrafanaAppBadge = ({ grafanaAppType }: { grafanaAppType: GrafanaAppReceiverEnum }) => { + const styles = useStyles2(getStyles); + return ( +
+ + + {grafanaAppType} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css` + text-align: left; + height: 22px; + display: inline-flex; + padding: 1px 4px; + border-radius: 3px; + border: 1px solid rgba(245, 95, 62, 1); + color: rgba(245, 95, 62, 1); + font-weight: ${theme.typography.fontWeightRegular}; + `, +}); diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/grafanaApp.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/grafanaApp.ts new file mode 100644 index 00000000000..90ffec98190 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/grafanaApp.ts @@ -0,0 +1,56 @@ +import { useGetSingleLocalWithoutDetails } from 'app/features/plugins/admin/state/hooks'; +import { CatalogPlugin } from 'app/features/plugins/admin/types'; +import { Receiver } from 'app/plugins/datasource/alertmanager/types'; + +import { useGetOnCallIntegrationsQuery } from '../../../api/onCallApi'; + +import { isOnCallReceiver } from './onCall/onCall'; +import { AmRouteReceiver, GrafanaAppReceiverEnum, GRAFANA_APP_PLUGIN_IDS, ReceiverWithTypes } from './types'; + +export const useGetAppIsInstalledAndEnabled = (grafanaAppType: GrafanaAppReceiverEnum) => { + // fetches the plugin settings for this Grafana instance + const plugin: CatalogPlugin | undefined = useGetSingleLocalWithoutDetails(GRAFANA_APP_PLUGIN_IDS[grafanaAppType]); + return plugin?.isInstalled && !plugin?.isDisabled && plugin?.type === 'app'; +}; + +export const useGetGrafanaReceiverTypeChecker = () => { + const isOnCallEnabled = useGetAppIsInstalledAndEnabled(GrafanaAppReceiverEnum.GRAFANA_ONCALL); + const { data } = useGetOnCallIntegrationsQuery(undefined, { + skip: !isOnCallEnabled, + }); + + const getGrafanaReceiverType = (receiver: Receiver): GrafanaAppReceiverEnum | undefined => { + //CHECK FOR ONCALL PLUGIN + const onCallIntegrations = data ?? []; + if (isOnCallEnabled && isOnCallReceiver(receiver, onCallIntegrations)) { + return GrafanaAppReceiverEnum.GRAFANA_ONCALL; + } + //WE WILL ADD IN HERE IF THERE ARE MORE TYPES TO CHECK + return undefined; + }; + return getGrafanaReceiverType; +}; + +export const useGetAmRouteReceiverWithGrafanaAppTypes = (receivers: Receiver[]) => { + const getGrafanaReceiverType = useGetGrafanaReceiverTypeChecker(); + const receiverToSelectableContactPointValue = (receiver: Receiver): AmRouteReceiver => { + const amRouteReceiverValue: AmRouteReceiver = { + label: receiver.name, + value: receiver.name, + grafanaAppReceiverType: getGrafanaReceiverType(receiver), + }; + return amRouteReceiverValue; + }; + + return receivers.map(receiverToSelectableContactPointValue); +}; + +export const useGetReceiversWithGrafanaAppTypes = (receivers: Receiver[]): ReceiverWithTypes[] => { + const getGrafanaReceiverType = useGetGrafanaReceiverTypeChecker(); + return receivers.map((receiver: Receiver) => { + return { + ...receiver, + grafanaAppReceiverType: getGrafanaReceiverType(receiver), + }; + }); +}; diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/onCall.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/onCall.ts new file mode 100644 index 00000000000..b64212666a2 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/onCall.ts @@ -0,0 +1,19 @@ +import { Receiver } from 'app/plugins/datasource/alertmanager/types'; + +export const isInOnCallIntegrations = (url: string, integrationsUrls: string[]) => { + return integrationsUrls.includes(url); +}; + +export const isOnCallReceiver = (receiver: Receiver, integrationsUrls: string[]) => { + if (!receiver.grafana_managed_receiver_configs) { + return false; + } + // A receiver it's an onCall contact point if it includes only one integration, and this integration it's an onCall + // An integration it's an onCall type if it's included in the list of integrations returned by the onCall api endpoint + const onlyOneIntegration = receiver.grafana_managed_receiver_configs.length === 1; + const isOncall = isInOnCallIntegrations( + receiver.grafana_managed_receiver_configs[0]?.settings?.url ?? '', + integrationsUrls + ); + return onlyOneIntegration && isOncall; +}; diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/types.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/types.ts new file mode 100644 index 00000000000..761a39f3559 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/types.ts @@ -0,0 +1,23 @@ +import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; +// we will add in here more types if needed +export enum GrafanaAppReceiverEnum { + GRAFANA_ONCALL = 'Grafana OnCall', +} + +export interface AmRouteReceiver { + label: string; + value: string; + grafanaAppReceiverType?: GrafanaAppReceiverEnum; +} + +export interface ReceiverWithTypes extends Receiver { + grafanaAppReceiverType?: GrafanaAppReceiverEnum; +} + +export const GRAFANA_APP_RECEIVERS_SOURCE_IMAGE = { + 'Grafana OnCall': 'public/img/alerting/oncall_logo.svg', +}; + +export enum GRAFANA_APP_PLUGIN_IDS { + 'Grafana OnCall' = 'grafana-oncall-app', +} diff --git a/public/app/features/alerting/unified/types/amroutes.ts b/public/app/features/alerting/unified/types/amroutes.ts index b52b56e489c..59c70e68983 100644 --- a/public/app/features/alerting/unified/types/amroutes.ts +++ b/public/app/features/alerting/unified/types/amroutes.ts @@ -17,8 +17,3 @@ export interface FormAmRoute { muteTimeIntervals: string[]; routes: FormAmRoute[]; } - -export interface AmRouteReceiver { - label: string; - value: string; -} diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 767d2f27b63..3b8af6a23d2 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -15,8 +15,8 @@ import { uninstallPlugin, } from '../api'; import { STATE_PREFIX } from '../constants'; -import { mergeLocalsAndRemotes, updatePanels } from '../helpers'; -import { CatalogPlugin, RemotePlugin } from '../types'; +import { mapLocalToCatalog, mergeLocalsAndRemotes, updatePanels } from '../helpers'; +import { CatalogPlugin, RemotePlugin, LocalPlugin } from '../types'; export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, thunkApi) => { try { @@ -33,6 +33,15 @@ export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, t } }); +export const fetchAllLocal = createAsyncThunk(`${STATE_PREFIX}/fetchAllLocal`, async (_, thunkApi) => { + try { + const localPlugins = await getLocalPlugins(); + return localPlugins.map((plugin: LocalPlugin) => mapLocalToCatalog(plugin)); + } catch (e) { + return thunkApi.rejectWithValue('Unknown error.'); + } +}); + export const fetchRemotePlugins = createAsyncThunk( `${STATE_PREFIX}/fetchRemotePlugins`, async (_, thunkApi) => { diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index a4032c82fa0..5d2277b1604 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -6,7 +6,7 @@ import { useDispatch, useSelector } from 'app/types'; import { sortPlugins, Sorters } from '../helpers'; import { CatalogPlugin, PluginListDisplayMode } from '../types'; -import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall } from './actions'; +import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall, fetchAllLocal } from './actions'; import { setDisplayMode } from './reducer'; import { find, @@ -58,6 +58,11 @@ export const useGetSingle = (id: string): CatalogPlugin | undefined => { return useSelector((state) => selectById(state, id)); }; +export const useGetSingleLocalWithoutDetails = (id: string): CatalogPlugin | undefined => { + useFetchAllLocal(); + return useSelector((state) => selectById(state, id)); +}; + export const useGetErrors = (): PluginError[] => { useFetchAll(); @@ -118,6 +123,16 @@ export const useFetchAll = () => { }, []); // eslint-disable-line }; +// Only fetches in case they were not fetched yet +export const useFetchAllLocal = () => { + const dispatch = useDispatch(); + const isNotFetched = useSelector(selectIsRequestNotFetched(fetchAllLocal.typePrefix)); + + useEffect(() => { + isNotFetched && dispatch(fetchAllLocal()); + }, []); // eslint-disable-line +}; + export const useFetchDetails = (id: string) => { const dispatch = useDispatch(); const plugin = useSelector((state) => selectById(state, id)); diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts index ea169b474f0..148032855b8 100644 --- a/public/app/features/plugins/admin/state/reducer.ts +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -5,7 +5,15 @@ import { PanelPlugin } from '@grafana/data'; import { STATE_PREFIX } from '../constants'; import { CatalogPlugin, PluginListDisplayMode, ReducerState, RequestStatus } from '../types'; -import { fetchAll, fetchDetails, install, uninstall, loadPluginDashboards, panelPluginLoaded } from './actions'; +import { + fetchAll, + fetchDetails, + install, + uninstall, + loadPluginDashboards, + panelPluginLoaded, + fetchAllLocal, +} from './actions'; export const pluginsAdapter = createEntityAdapter(); @@ -54,6 +62,10 @@ const slice = createSlice({ .addCase(fetchAll.fulfilled, (state, action) => { pluginsAdapter.upsertMany(state.items, action.payload); }) + // Fetch All local + .addCase(fetchAllLocal.fulfilled, (state, action) => { + pluginsAdapter.upsertMany(state.items, action.payload); + }) // Fetch Details .addCase(fetchDetails.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); diff --git a/public/img/alerting/oncall_logo.svg b/public/img/alerting/oncall_logo.svg new file mode 100644 index 00000000000..f911bd838dc --- /dev/null +++ b/public/img/alerting/oncall_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file