- {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)}
- />
-
-
- }
- >
-
-
+ {menuActions.length > 0 && (
+
{menuActions}}>
+
+
+ )}
+ {ExportDrawer}
);
};
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 ;
+};
+
+interface ReceiversProps {
+ receivers: ReceiverConfigWithMetadata[];
+}
+
+const ReceiversSummary = ({ receivers }: ReceiversProps) => {
+ return (
+
+ {receivers.map((receiver, index) => (
+
+ {receiver[RECEIVER_PLUGIN_META_KEY]?.icon && (
+
+ )}
+
+ {receiver[RECEIVER_META_KEY].name ?? receiver[RECEIVER_PLUGIN_META_KEY]?.title ?? receiver.type}
+
+
+ ))}
+
+ );
+};
diff --git a/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx
index 34dd5207170..0a3dcf095e5 100644
--- a/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx
+++ b/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import { Alert } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
+import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { CloudReceiverForm } from './form/CloudReceiverForm';
@@ -15,6 +16,8 @@ interface Props {
}
export const EditReceiverView = ({ config, receiverName, alertManagerSourceName }: Props) => {
+ const [editSupported, editAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint);
+
const receiver = config.alertmanager_config.receivers?.find(({ name }) => name === receiverName);
if (!receiver) {
return (
@@ -24,9 +27,25 @@ export const EditReceiverView = ({ config, receiverName, alertManagerSourceName
);
}
+ const readOnly = !editSupported || !editAllowed;
+
if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) {
- return ;
+ return (
+
+ );
} else {
- return ;
+ return (
+
+ );
}
};
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 7195e2b70fb..fe025f0ed36 100644
--- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx
+++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx
@@ -24,7 +24,7 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { ReceiversTable } from './ReceiversTable';
import * as receiversMeta from './grafanaAppReceivers/useReceiversMetadata';
-import { ReceiverMetadata } from './grafanaAppReceivers/useReceiversMetadata';
+import { ReceiverPluginMetadata } from './grafanaAppReceivers/useReceiversMetadata';
jest.mock('react-virtualized-auto-sizer', () => {
return ({ children }: AutoSizerProps) => children({ height: 600, width: 1 });
@@ -101,7 +101,7 @@ describe('ReceiversTable', () => {
jest.resetAllMocks();
const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount: 0 };
useGetContactPointsStateMock.mockReturnValue(emptyContactPointsState);
- useReceiversMetadata.mockReturnValue(new Map());
+ useReceiversMetadata.mockReturnValue(new Map());
});
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 af6f8634a0b..cebab895040 100644
--- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx
+++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx
@@ -26,7 +26,7 @@ import { ActionIcon } from '../rules/ActionIcon';
import { ReceiversSection } from './ReceiversSection';
import { ReceiverMetadataBadge } from './grafanaAppReceivers/ReceiverMetadataBadge';
-import { ReceiverMetadata, useReceiversMetadata } from './grafanaAppReceivers/useReceiversMetadata';
+import { ReceiverPluginMetadata, useReceiversMetadata } from './grafanaAppReceivers/useReceiversMetadata';
import { AlertmanagerConfigHealth, useAlertmanagerConfigHealth } from './useAlertmanagerConfigHealth';
interface UpdateActionProps extends ActionProps {
@@ -174,7 +174,7 @@ interface ReceiverItem {
types: string[];
provisioned?: boolean;
grafanaAppReceiverType?: SupportedPlugin;
- metadata?: ReceiverMetadata;
+ metadata?: ReceiverPluginMetadata;
}
interface NotifierStatus {
diff --git a/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx
index 65fa7312c93..8ec0b6d11e4 100644
--- a/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx
+++ b/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx
@@ -23,6 +23,7 @@ interface Props {
alertManagerSourceName: string;
config: AlertManagerCortexConfig;
existing?: Receiver;
+ readOnly?: boolean;
}
const defaultChannelValues: CloudChannelValues = Object.freeze({
@@ -36,7 +37,7 @@ const defaultChannelValues: CloudChannelValues = Object.freeze({
const cloudNotifiers = cloudNotifierTypes.map((n) => ({ dto: n }));
-export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }: Props) => {
+export const CloudReceiverForm = ({ existing, alertManagerSourceName, config, readOnly = false }: Props) => {
const dispatch = useDispatch();
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
@@ -70,7 +71,8 @@ export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }:
// this basically checks if we can manage the selected alert manager data source, either because it's a Grafana Managed one
// or a Mimir-based AlertManager
- const isManageableAlertManagerDataSource = !isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
+ const isManageableAlertManagerDataSource =
+ !readOnly ?? !isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
return (
<>
diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx
index ee769573b6a..f0492dcf620 100644
--- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx
+++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx
@@ -12,7 +12,7 @@ import { useDispatch } from 'app/types';
import { alertmanagerApi } from '../../../api/alertmanagerApi';
import { testReceiversAction, updateAlertManagerConfigAction } from '../../../state/actions';
import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
-import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../../utils/datasource';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import {
formChannelValuesToGrafanaChannelConfig,
formValuesToGrafanaReceiver,
@@ -32,6 +32,7 @@ interface Props {
alertManagerSourceName: string;
config: AlertManagerCortexConfig;
existing?: GrafanaManagedContactPoint;
+ readOnly?: boolean;
}
const defaultChannelValues: GrafanaChannelValues = Object.freeze({
@@ -43,7 +44,7 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({
type: 'email',
});
-export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }: Props) => {
+export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config, readOnly = false }: Props) => {
const dispatch = useDispatch();
const {
@@ -125,12 +126,8 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }
? (existing.grafana_managed_receiver_configs ?? []).some((item) => Boolean(item.provenance))
: false;
- // this basically checks if we can manage the selected alert manager data source, either because it's a Grafana Managed one
- // or a Mimir-based AlertManager
- const isManageableAlertManagerDataSource = !isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
-
- const isEditable = isManageableAlertManagerDataSource && !hasProvisionedItems;
- const isTestable = isManageableAlertManagerDataSource || hasProvisionedItems;
+ const isEditable = !readOnly && !hasProvisionedItems;
+ const isTestable = !readOnly;
if (isLoadingNotifiers || isLoadingOnCallIntegration) {
return ;
diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/ReceiverMetadataBadge.tsx b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/ReceiverMetadataBadge.tsx
index fa9e1f395c0..fe4fbd2c1b4 100644
--- a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/ReceiverMetadataBadge.tsx
+++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/ReceiverMetadataBadge.tsx
@@ -3,47 +3,38 @@ import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
-import { HorizontalGroup, Icon, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
+import { Icon, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
-import { ReceiverMetadata } from './useReceiversMetadata';
+import { ReceiverPluginMetadata } from './useReceiversMetadata';
interface Props {
- metadata: ReceiverMetadata;
+ metadata: ReceiverPluginMetadata;
}
export const ReceiverMetadataBadge = ({ metadata: { icon, title, externalUrl, warning } }: Props) => {
const styles = useStyles2(getStyles);
return (
-
-
-
-
- {title}
-
-
- {externalUrl && }
- {warning && (
-
-
-
+
+
+ {warning ? (
+
+
+
+ ) : (
+
+ )}
+ {title}
+
+ {externalUrl && (
+
)}
);
};
const getStyles = (theme: GrafanaTheme2) => ({
- wrapper: css`
- text-align: left;
- height: 22px;
- display: inline-flex;
- padding: 1px 4px;
- border-radius: ${theme.shape.borderRadius()};
- border: 1px solid rgba(245, 95, 62, 1);
- color: rgba(245, 95, 62, 1);
- font-weight: ${theme.typography.fontWeightRegular};
- `,
- warnIcon: css`
- fill: ${theme.colors.warning.main};
- `,
+ warnIcon: css({
+ fill: theme.colors.warning.text,
+ }),
});
diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts
index 40ba9f521ab..8f00947029e 100644
--- a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts
+++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/useReceiversMetadata.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
-import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
-import { onCallApi } from '../../../api/onCallApi';
+import { GrafanaManagedReceiverConfig, Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
+import { onCallApi, OnCallIntegrationDTO } from '../../../api/onCallApi';
import { usePluginBridge } from '../../../hooks/usePluginBridge';
import { SupportedPlugin } from '../../../types/pluginBridges';
import { createBridgeURL } from '../../PluginBridge';
@@ -9,9 +9,10 @@ import { createBridgeURL } from '../../PluginBridge';
import { ReceiverTypes } from './onCall/onCall';
import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from './types';
-export interface ReceiverMetadata {
+export interface ReceiverPluginMetadata {
icon: string;
title: string;
+ description?: string;
externalUrl?: string;
warning?: string;
}
@@ -19,46 +20,59 @@ export interface ReceiverMetadata {
const onCallReceiverICon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[SupportedPlugin.OnCall];
const onCallReceiverTitle = 'Grafana OnCall';
-const onCallReceiverMeta: ReceiverMetadata = {
+const onCallReceiverMeta: ReceiverPluginMetadata = {
title: onCallReceiverTitle,
icon: onCallReceiverICon,
};
-export const useReceiversMetadata = (receivers: Receiver[]): Map => {
+export const useReceiversMetadata = (receivers: Receiver[]): Map => {
const { installed: isOnCallEnabled } = usePluginBridge(SupportedPlugin.OnCall);
const { data: onCallIntegrations = [] } = onCallApi.useGrafanaOnCallIntegrationsQuery(undefined, {
skip: !isOnCallEnabled,
});
return useMemo(() => {
- const result = new Map();
+ const result = new Map();
receivers.forEach((receiver) => {
const onCallReceiver = receiver.grafana_managed_receiver_configs?.find((c) => c.type === ReceiverTypes.OnCall);
if (onCallReceiver) {
if (!isOnCallEnabled) {
- result.set(receiver, {
- ...onCallReceiverMeta,
- warning: 'Grafana OnCall is not enabled',
- });
+ result.set(receiver, getOnCallMetadata(null, onCallReceiver));
return;
}
- const matchingOnCallIntegration = onCallIntegrations.find(
- (i) => i.integration_url === onCallReceiver.settings.url
- );
-
- result.set(receiver, {
- ...onCallReceiverMeta,
- externalUrl: matchingOnCallIntegration
- ? createBridgeURL(SupportedPlugin.OnCall, `/integrations/${matchingOnCallIntegration.value}`)
- : undefined,
- warning: matchingOnCallIntegration ? undefined : 'OnCall Integration no longer exists',
- });
+ result.set(receiver, getOnCallMetadata(onCallIntegrations, onCallReceiver));
}
});
return result;
}, [isOnCallEnabled, receivers, onCallIntegrations]);
};
+
+export function getOnCallMetadata(
+ onCallIntegrations: OnCallIntegrationDTO[] | null,
+ receiver: GrafanaManagedReceiverConfig
+): ReceiverPluginMetadata {
+ // indication that onCall is not enabled
+ if (onCallIntegrations == null) {
+ return {
+ ...onCallReceiverMeta,
+ warning: 'Grafana OnCall is not installed or is disabled',
+ };
+ }
+
+ const matchingOnCallIntegration = onCallIntegrations.find(
+ (integration) => integration.integration_url === receiver.settings.url
+ );
+
+ return {
+ ...onCallReceiverMeta,
+ description: matchingOnCallIntegration?.display_name,
+ externalUrl: matchingOnCallIntegration
+ ? createBridgeURL(SupportedPlugin.OnCall, `/integrations/${matchingOnCallIntegration.value}`)
+ : undefined,
+ warning: matchingOnCallIntegration ? undefined : 'OnCall Integration no longer exists',
+ };
+}
diff --git a/public/app/features/alerting/unified/features.ts b/public/app/features/alerting/unified/features.ts
index 76447761dea..a21910db187 100644
--- a/public/app/features/alerting/unified/features.ts
+++ b/public/app/features/alerting/unified/features.ts
@@ -13,10 +13,6 @@ const FEATURES: FeatureDescription[] = [
name: AlertingFeature.NotificationPoliciesV2MatchingInstances,
defaultValue: config.featureToggles.alertingNotificationsPoliciesMatchingInstances,
},
- {
- name: AlertingFeature.ContactPointsV2,
- defaultValue: false,
- },
{
name: AlertingFeature.DetailsViewV2,
defaultValue: false,