Alerting: Contact points v2 part 3 (#72444)

pull/75269/head
Gilles De Mey 2 years ago committed by GitHub
parent 440f9a6ffb
commit 5a1580c659
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      .betterer.results
  2. 1
      packages/grafana-data/src/types/icon.ts
  3. 8
      packages/grafana-ui/src/components/Button/Button.tsx
  4. 9
      public/app/features/alerting/components/ConditionalWrap.tsx
  5. 27
      public/app/features/alerting/unified/Receivers.tsx
  6. 4
      public/app/features/alerting/unified/components/MetaText.tsx
  7. 122
      public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.test.tsx
  8. 377
      public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx
  9. 37
      public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx
  10. 47
      public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx
  11. 47
      public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx
  12. 32
      public/app/features/alerting/unified/components/contact-points/GlobalConfig.tsx
  13. 22
      public/app/features/alerting/unified/components/contact-points/MessageTemplates.tsx
  14. 33
      public/app/features/alerting/unified/components/contact-points/NewContactPoint.tsx
  15. 32
      public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx
  16. 34
      public/app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.mimir.config.mock.json
  17. 24
      public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts
  18. 23
      public/app/features/alerting/unified/components/contact-points/__mocks__/mimirFlavoredServer.ts
  19. 4
      public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap
  20. 4
      public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx
  21. 29
      public/app/features/alerting/unified/components/contact-points/utils.ts
  22. 47
      public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx
  23. 2
      public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx
  24. 16
      public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx
  25. 5
      public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx
  26. 4
      public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx
  27. 12
      public/app/features/alerting/unified/components/receivers/useAlertmanagerConfigHealth.ts
  28. 2
      public/app/features/alerting/unified/types/contact-points.ts

@ -2108,13 +2108,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/alerting/unified/components/export/FileExportPreview.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],

@ -6,6 +6,7 @@ export const availableIconsIndex = {
okta: true,
discord: true,
hipchat: true,
amazon: true,
'google-hangouts-alt': true,
pagerduty: true,
line: true,

@ -126,7 +126,13 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
// When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632
const button = (
<a className={linkButtonStyles} {...otherProps} tabIndex={disabled ? -1 : 0} ref={tooltip ? undefined : ref}>
<a
className={linkButtonStyles}
{...otherProps}
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
ref={tooltip ? undefined : ref}
>
{icon && <Icon name={icon} size={size} className={styles.icon} />}
{children && <span className={styles.content}>{children}</span>}
</a>

@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef, Ref } from 'react';
interface ConditionalWrapProps {
shouldWrap: boolean;
@ -6,7 +6,8 @@ interface ConditionalWrapProps {
wrap: (children: JSX.Element) => JSX.Element;
}
export const ConditionalWrap = ({ shouldWrap, children, wrap }: ConditionalWrapProps): JSX.Element =>
shouldWrap ? React.cloneElement(wrap(children)) : children;
function ConditionalWrap({ children, shouldWrap, wrap }: ConditionalWrapProps, _ref: Ref<HTMLElement>) {
return shouldWrap ? React.cloneElement(wrap(children)) : children;
}
export default ConditionalWrap;
export default forwardRef(ConditionalWrap);

@ -1,19 +1,42 @@
import React from 'react';
import { Disable, Enable } from 'react-enable';
import { Route, Switch } from 'react-router-dom';
import { withErrorBoundary } from '@grafana/ui';
const ContactPointsV1 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v1'));
const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v2'));
const EditContactPoint = SafeDynamicImport(() => import('./components/contact-points/EditContactPoint'));
const NewContactPoint = SafeDynamicImport(() => import('./components/contact-points/NewContactPoint'));
const EditMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/EditMessageTemplate'));
const NewMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/NewMessageTemplate'));
const GlobalConfig = SafeDynamicImport(() => import('./components/contact-points/GlobalConfig'));
const DuplicateMessageTemplate = SafeDynamicImport(
() => import('./components/contact-points/DuplicateMessageTemplate')
);
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { AlertingFeature } from './features';
// TODO add pagenav back in – what are we missing if we don't specify it?
// TODO add pagenav back in – that way we have correct breadcrumbs and page title
const ContactPoints = (props: GrafanaRouteComponentProps): JSX.Element => (
<AlertmanagerPageWrapper pageId="receivers" accessType="notification">
<Enable feature={AlertingFeature.ContactPointsV2}>
<ContactPointsV2 {...props} />
{/* TODO do we want a "routes" component for each Alerting entity? */}
<Switch>
<Route exact={true} path="/alerting/notifications" component={ContactPointsV2} />
<Route exact={true} path="/alerting/notifications/receivers/new" component={NewContactPoint} />
<Route exact={true} path="/alerting/notifications/receivers/:name/edit" component={EditContactPoint} />
<Route exact={true} path="/alerting/notifications/templates/:name/edit" component={EditMessageTemplate} />
<Route exact={true} path="/alerting/notifications/templates/new" component={NewMessageTemplate} />
<Route
exact={true}
path="/alerting/notifications/templates/:name/duplicate"
component={DuplicateMessageTemplate}
/>
<Route exact={true} path="/alerting/notifications/global-config" component={GlobalConfig} />
</Switch>
</Enable>
<Disable feature={AlertingFeature.ContactPointsV2}>
<ContactPointsV1 {...props} />

@ -21,9 +21,9 @@ const MetaText = ({ children, icon, color = 'secondary', ...rest }: Props) => {
// allow passing ARIA and data- attributes
{...rest}
>
<Text color={color}>
<Text variant="bodySmall" color={color}>
<Stack direction="row" alignItems="center" gap={0.5}>
{icon && <Icon name={icon} />}
{icon && <Icon size="sm" name={icon} />}
{children}
</Stack>
</Text>

@ -1,17 +1,20 @@
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { noop } from 'lodash';
import React from 'react';
import React, { PropsWithChildren } from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { selectors } from '@grafana/e2e-selectors';
import { AccessControlAction } from 'app/types';
import { disableRBAC } from '../../mocks';
import { grantUserPermissions, mockDataSource } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { setupDataSources } from '../../testSetup/datasources';
import { DataSourceType } from '../../utils/datasource';
import ContactPoints, { ContactPoint } from './ContactPoints.v2';
import './__mocks__/server';
import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer';
import setupMimirFlavoredServer, { MIMIR_DATASOURCE_UID } from './__mocks__/mimirFlavoredServer';
/**
* There are lots of ways in which we test our pages and components. Here's my opinionated approach to testing them.
@ -28,8 +31,14 @@ import './__mocks__/server';
* if those have any logic or data structure transformations in them.
*/
describe('ContactPoints', () => {
describe('Grafana managed alertmanager', () => {
setupGrafanaManagedServer();
beforeAll(() => {
disableRBAC();
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
]);
});
it('should show / hide loading states', async () => {
@ -41,23 +50,63 @@ describe('ContactPoints', () => {
);
await waitFor(async () => {
await expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitForElementToBeRemoved(screen.getByText('Loading...'));
await expect(screen.queryByTestId(selectors.components.Alert.alertV2('error'))).not.toBeInTheDocument();
expect(screen.queryByTestId(selectors.components.Alert.alertV2('error'))).not.toBeInTheDocument();
});
expect(screen.getByText('grafana-default-email')).toBeInTheDocument();
expect(screen.getAllByTestId('contact-point')).toHaveLength(4);
});
});
describe('Mimir-flavored alertmanager', () => {
setupMimirFlavoredServer();
beforeAll(() => {
grantUserPermissions([
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
]);
setupDataSources(
mockDataSource({
type: DataSourceType.Alertmanager,
name: MIMIR_DATASOURCE_UID,
uid: MIMIR_DATASOURCE_UID,
})
);
});
it('should show / hide loading states', async () => {
render(
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={MIMIR_DATASOURCE_UID}>
<ContactPoints />
</AlertmanagerProvider>,
{ wrapper: TestProvider }
);
await waitFor(async () => {
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitForElementToBeRemoved(screen.getByText('Loading...'));
expect(screen.queryByTestId(selectors.components.Alert.alertV2('error'))).not.toBeInTheDocument();
});
expect(screen.getByText('mixed')).toBeInTheDocument();
expect(screen.getByText('some webhook')).toBeInTheDocument();
expect(screen.getAllByTestId('contact-point')).toHaveLength(2);
});
});
});
describe('ContactPoint', () => {
it('should call delete when clicked and not disabled', async () => {
const onDelete = jest.fn();
render(<ContactPoint name={'my-contact-point'} receivers={[]} onDelete={onDelete} />);
render(<ContactPoint name={'my-contact-point'} receivers={[]} onDelete={onDelete} />, {
wrapper,
});
const moreActions = screen.getByTestId('more-actions');
const moreActions = screen.getByRole('button', { name: 'more-actions' });
await userEvent.click(moreActions);
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
@ -66,25 +115,56 @@ describe('ContactPoint', () => {
expect(onDelete).toHaveBeenCalledWith('my-contact-point');
});
it('should disabled buttons', async () => {
render(<ContactPoint name={'my-contact-point'} disabled={true} receivers={[]} onDelete={noop} />);
it('should disable edit button', async () => {
render(<ContactPoint name={'my-contact-point'} disabled={true} receivers={[]} onDelete={noop} />, {
wrapper,
});
const moreActions = screen.getByTestId('more-actions');
const editAction = screen.getByTestId('edit-action');
const moreActions = screen.getByRole('button', { name: 'more-actions' });
expect(moreActions).not.toBeDisabled();
expect(moreActions).toHaveProperty('disabled', true);
expect(editAction).toHaveProperty('disabled', true);
const editAction = screen.getByTestId('edit-action');
expect(editAction).toHaveAttribute('aria-disabled', 'true');
});
it('should disabled buttons when provisioned', async () => {
render(<ContactPoint name={'my-contact-point'} provisioned={true} receivers={[]} onDelete={noop} />);
it('should disable buttons when provisioned', async () => {
render(<ContactPoint name={'my-contact-point'} provisioned={true} receivers={[]} onDelete={noop} />, {
wrapper,
});
expect(screen.getByText(/provisioned/i)).toBeInTheDocument();
const moreActions = screen.getByTestId('more-actions');
const editAction = screen.getByTestId('edit-action');
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);
expect(moreActions).toHaveProperty('disabled', true);
expect(editAction).toHaveProperty('disabled', true);
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(<ContactPoint name={'my-contact-point'} provisioned={true} receivers={[]} policies={1} onDelete={noop} />, {
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) => (
<TestProvider>
<AlertmanagerProvider accessType={'notification'}>{children}</AlertmanagerProvider>
</TestProvider>
);

@ -1,63 +1,195 @@
import { css } from '@emotion/css';
import { SerializedError } from '@reduxjs/toolkit';
import { uniqueId, upperFirst } from 'lodash';
import React, { ReactNode } from 'react';
import { groupBy, size, uniqueId, upperFirst } from 'lodash';
import pluralize from 'pluralize';
import React, { ReactNode, useState } from 'react';
import { Link } from 'react-router-dom';
import { dateTime, GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Alert, Button, Dropdown, Icon, LoadingPlaceholder, Menu, Tooltip, useStyles2, Text } from '@grafana/ui';
import {
Alert,
Button,
Dropdown,
Icon,
LoadingPlaceholder,
Menu,
Tooltip,
useStyles2,
Text,
LinkButton,
TabsBar,
TabContent,
Tab,
Pagination,
} 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 { 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 { createUrl } from '../../utils/url';
import { MetaText } from '../MetaText';
import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
import { Strong } from '../Strong';
import { GlobalConfigAlert } from '../receivers/ReceiversAndTemplatesView';
import { UnusedContactPointBadge } from '../receivers/ReceiversTable';
import { MessageTemplates } from './MessageTemplates';
import { useDeleteContactPointModal } from './Modals';
import { RECEIVER_STATUS_KEY, useContactPointsWithStatus, useDeleteContactPoint } from './useContactPoints';
import { getReceiverDescription, isProvisioned, ReceiverConfigWithStatus } from './utils';
import { ContactPointWithStatus, getReceiverDescription, isProvisioned, ReceiverConfigWithStatus } from './utils';
enum ActiveTab {
ContactPoints,
MessageTemplates,
}
const DEFAULT_PAGE_SIZE = 25;
const ContactPoints = () => {
const { selectedAlertmanager } = useAlertmanager();
const { isLoading, error, contactPoints } = useContactPointsWithStatus(selectedAlertmanager!);
// TODO hook up to query params
const [activeTab, setActiveTab] = useState<ActiveTab>(ActiveTab.ContactPoints);
let { isLoading, error, contactPoints } = useContactPointsWithStatus(selectedAlertmanager!);
const { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!);
const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading);
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
const showingMessageTemplates = activeTab === ActiveTab.MessageTemplates;
if (error) {
// TODO fix this type casting, when error comes from "getContactPointsStatus" it probably won't be a SerializedError
return <Alert title="Failed to fetch contact points">{(error as SerializedError).message}</Alert>;
}
if (isLoading) {
return <LoadingPlaceholder text={'Loading...'} />;
}
const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
const isVanillaAlertmanager = isVanillaPrometheusAlertManagerDataSource(selectedAlertmanager!);
const permissions = getNotificationsPermissions(selectedAlertmanager!);
const allowedToAddContactPoint = contextSrv.hasPermission(permissions.create);
return (
<>
<Stack direction="column">
{contactPoints.map((contactPoint) => {
const contactPointKey = selectedAlertmanager + contactPoint.name;
<TabsBar>
<Tab
label="Contact Points"
active={showingContactPoints}
counter={contactPoints.length}
onChangeTab={() => setActiveTab(ActiveTab.ContactPoints)}
/>
<Tab
label="Message Templates"
active={showingMessageTemplates}
onChangeTab={() => setActiveTab(ActiveTab.MessageTemplates)}
/>
<Spacer />
{showingContactPoints && (
<LinkButton
icon="plus"
variant="primary"
href="/alerting/notifications/receivers/new"
// TODO clarify why the button has been disabled
disabled={!allowedToAddContactPoint || isVanillaAlertmanager}
>
Add contact point
</LinkButton>
)}
{showingMessageTemplates && (
<LinkButton icon="plus" variant="primary" href="/alerting/notifications/templates/new">
Add message template
</LinkButton>
)}
</TabsBar>
<TabContent>
<Stack direction="column">
<>
{isLoading && <LoadingPlaceholder text={'Loading...'} />}
{/* Contact Points tab */}
{showingContactPoints && (
<>
{error ? (
<Alert title="Failed to fetch contact points">{String(error)}</Alert>
) : (
<>
{/* TODO we can add some additional info here with a ToggleTip */}
<Text variant="body" color="secondary">
Define where notifications are sent, a contact point can contain multiple integrations.
</Text>
<ContactPointsList
contactPoints={contactPoints}
pageSize={DEFAULT_PAGE_SIZE}
onDelete={(name) => showDeleteModal(name)}
disabled={updateAlertmanagerState.isLoading}
/>
{/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */}
{!isGrafanaManagedAlertmanager && <GlobalConfigAlert alertManagerName={selectedAlertmanager!} />}
</>
)}
</>
)}
{/* Message Templates tab */}
{showingMessageTemplates && (
<>
<Text variant="body" color="secondary">
Create message templates to customize your notifications.
</Text>
<MessageTemplates />
</>
)}
</>
</Stack>
</TabContent>
</Stack>
{DeleteModal}
</>
);
};
interface ContactPointsListProps {
contactPoints: ContactPointWithStatus[];
disabled?: boolean;
onDelete: (name: string) => void;
pageSize?: number;
}
const ContactPointsList = ({
contactPoints,
disabled = false,
pageSize = DEFAULT_PAGE_SIZE,
onDelete,
}: ContactPointsListProps) => {
const { page, pageItems, numberOfPages, onPageChange } = usePagination(contactPoints, 1, pageSize);
return (
<>
{pageItems.map((contactPoint, index) => {
const provisioned = isProvisioned(contactPoint);
const disabled = updateAlertmanagerState.isLoading;
const policies = contactPoint.numberOfPolicies;
return (
<ContactPoint
key={contactPointKey}
key={`${contactPoint.name}-${index}`}
name={contactPoint.name}
disabled={disabled}
onDelete={showDeleteModal}
onDelete={onDelete}
receivers={contactPoint.grafana_managed_receiver_configs}
provisioned={provisioned}
policies={policies}
/>
);
})}
</Stack>
{DeleteModal}
<Pagination currentPage={page} numberOfPages={numberOfPages} onNavigate={onPageChange} hideWhenSinglePage />
</>
);
};
@ -67,6 +199,7 @@ interface ContactPointProps {
disabled?: boolean;
provisioned?: boolean;
receivers: ReceiverConfigWithStatus[];
policies?: number;
onDelete: (name: string) => void;
}
@ -75,21 +208,26 @@ export const ContactPoint = ({
disabled = false,
provisioned = false,
receivers,
policies = 0,
onDelete,
}: ContactPointProps) => {
const styles = useStyles2(getStyles);
// TODO probably not the best way to figure out if we want to show either only the summary or full metadata for the receivers?
const showFullMetadata = receivers.some((receiver) => Boolean(receiver[RECEIVER_STATUS_KEY]));
return (
<div className={styles.contactPointWrapper} data-testid="contact-point">
<Stack direction="column" gap={0}>
<ContactPointHeader
name={name}
policies={[]}
policies={policies}
provisioned={provisioned}
disabled={disabled}
onDelete={onDelete}
/>
<div className={styles.receiversWrapper}>
{showFullMetadata ? (
<div>
{receivers?.map((receiver) => {
const diagnostics = receiver[RECEIVER_STATUS_KEY];
const sendingResolved = !Boolean(receiver.disableResolveMessage);
@ -105,6 +243,11 @@ export const ContactPoint = ({
);
})}
</div>
) : (
<div>
<ContactPointReceiverSummary receivers={receivers} />
</div>
)}
</Stack>
</div>
);
@ -114,64 +257,97 @@ interface ContactPointHeaderProps {
name: string;
disabled?: boolean;
provisioned?: boolean;
policies?: string[]; // some array of policies that refer to this contact point
policies?: number;
onDelete: (name: string) => void;
}
const ContactPointHeader = (props: ContactPointHeaderProps) => {
const { name, disabled = false, provisioned = false, policies = [], onDelete } = props;
const { name, disabled = false, provisioned = false, policies = 0, onDelete } = props;
const styles = useStyles2(getStyles);
const { selectedAlertmanager } = useAlertmanager();
const permissions = getNotificationsPermissions(selectedAlertmanager ?? '');
const isReferencedByPolicies = policies > 0;
const isGranaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
const disableActions = disabled || provisioned;
// we make a distinction here becase for "canExport" we show the menu item, if not we hide it
const canExport = isGranaManagedAlertmanager;
const allowedToExport = contextSrv.hasAccess(permissions.provisioning.read, isOrgAdmin());
return (
<div className={styles.headerWrapper}>
<Stack direction="row" alignItems="center" gap={1}>
<Stack alignItems="center" gap={1}>
<Text variant="body">{name}</Text>
<Text variant="body" weight="medium">
{name}
</Text>
</Stack>
{policies.length > 0 ? (
{isReferencedByPolicies ? (
<MetaText>
{/* TODO make this a link to the notification policies page with the filter applied */}
is used by <Strong>{policies.length}</Strong> notification policies
<Link to={createUrl('/alerting/routes', { contactPoint: name })}>
is used by <Strong>{policies}</Strong> {pluralize('notification policy', policies)}
</Link>
</MetaText>
) : (
<MetaText>is not used in any policy</MetaText>
<UnusedContactPointBadge />
)}
{provisioned && <ProvisioningBadge />}
<Spacer />
<ConditionalWrap
shouldWrap={provisioned}
wrap={(children) => (
<Tooltip content="Provisioned items cannot be edited in the UI" placement="top">
{children}
</Tooltip>
)}
>
<Button
<LinkButton
tooltipPlacement="top"
tooltip={provisioned ? 'Provisioned contact points cannot be edited in the UI' : undefined}
variant="secondary"
size="sm"
icon="edit"
icon={provisioned ? 'document-info' : 'edit'}
type="button"
disabled={disableActions}
aria-label="edit-action"
data-testid="edit-action"
disabled={disabled}
aria-label={`${provisioned ? 'view' : 'edit'}-action`}
data-testid={`${provisioned ? 'view' : 'edit'}-action`}
href={`/alerting/notifications/receivers/${encodeURIComponent(name)}/edit`}
>
Edit
</Button>
</ConditionalWrap>
{provisioned ? 'View' : 'Edit'}
</LinkButton>
{/* TODO probably want to split this off since there's lots of RBAC involved here */}
<Dropdown
overlay={
<Menu>
<Menu.Item label="Export" icon="download-alt" />
{canExport && (
<>
<Menu.Item
icon="download-alt"
label={isOrgAdmin() ? 'Export' : 'Export redacted'}
disabled={!allowedToExport}
url={createUrl(`/api/v1/provisioning/contact-points/export/`, {
download: 'true',
format: 'yaml',
decrypt: isOrgAdmin().toString(),
name: name,
})}
target="_blank"
data-testid="export"
/>
<Menu.Divider />
</>
)}
<ConditionalWrap
shouldWrap={policies > 0}
wrap={(children) => (
<Tooltip
content={'Contact point is currently in use by one or more notification policies'}
placement="top"
>
<span>{children}</span>
</Tooltip>
)}
>
<Menu.Item
label="Delete"
icon="trash-alt"
destructive
disabled={disableActions}
disabled={disabled || provisioned || policies > 0}
onClick={() => onDelete(name)}
/>
</ConditionalWrap>
</Menu>
}
>
@ -182,7 +358,6 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
type="button"
aria-label="more-actions"
data-testid="more-actions"
disabled={disableActions}
/>
</Dropdown>
</Stack>
@ -203,13 +378,13 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => {
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 (
<div className={styles.integrationWrapper}>
<Stack direction="column" gap={0}>
<div className={styles.receiverDescriptionRow}>
<Stack direction="column" gap={0.5}>
<Stack direction="row" alignItems="center" gap={1}>
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
@ -223,7 +398,6 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => {
</Text>
)}
</Stack>
</div>
{hasMetadata && <ContactPointReceiverMetadataRow diagnostics={diagnostics} sendingResolved={sendingResolved} />}
</Stack>
</div>
@ -235,8 +409,47 @@ interface ContactPointReceiverMetadata {
diagnostics: NotifierStatus;
}
const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) => {
const { diagnostics, sendingResolved } = props;
type ContactPointReceiverSummaryProps = {
receivers: GrafanaManagedReceiverConfig[];
};
/**
* This summary is used when we're dealing with non-Grafana managed alertmanager since they
* don't have any metadata worth showing other than a summary of what types are configured for the contact point
*/
const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => {
const styles = useStyles2(getStyles);
const countByType = groupBy(receivers, (receiver) => receiver.type);
return (
<div className={styles.integrationWrapper}>
<Stack direction="column" gap={0}>
<Stack direction="row" alignItems="center" gap={1}>
{Object.entries(countByType).map(([type, receivers], index) => {
const iconName = INTEGRATION_ICONS[type];
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
const isLastItem = size(countByType) - 1 === index;
return (
<React.Fragment key={type}>
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
<Text variant="body" color="primary">
{receiverName}
{receivers.length > 1 && <> ({receivers.length})</>}
</Text>
</Stack>
{!isLastItem && '⋅'}
</React.Fragment>
);
})}
</Stack>
</Stack>
</div>
);
};
const ContactPointReceiverMetadataRow = ({ diagnostics, sendingResolved }: ContactPointReceiverMetadata) => {
const styles = useStyles2(getStyles);
const failedToSend = Boolean(diagnostics.lastNotifyAttemptError);
@ -250,16 +463,11 @@ const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) =>
{/* this is shown when the last delivery failed – we don't show any additional metadata */}
{failedToSend ? (
<>
{/* TODO we might need an error variant for MetaText, dito for success */}
<Text color="error" variant="bodySmall" weight="bold">
<Stack direction="row" alignItems={'center'} gap={0.5}>
<MetaText color="error" icon="exclamation-circle">
<Tooltip content={diagnostics.lastNotifyAttemptError!}>
<span>
<Icon name="exclamation-circle" /> Last delivery attempt failed
</span>
<span>Last delivery attempt failed</span>
</Tooltip>
</Stack>
</Text>
</MetaText>
</>
) : (
<>
@ -295,36 +503,31 @@ const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) =>
};
const getStyles = (theme: GrafanaTheme2) => ({
contactPointWrapper: css`
border-radius: ${theme.shape.radius.default};
border: solid 1px ${theme.colors.border.weak};
border-bottom: none;
`,
integrationWrapper: css`
position: relative;
background: ${theme.colors.background.primary};
border-bottom: solid 1px ${theme.colors.border.weak};
`,
headerWrapper: css`
padding: ${theme.spacing(1)} ${theme.spacing(1.5)};
background: ${theme.colors.background.secondary};
border-bottom: solid 1px ${theme.colors.border.weak};
border-top-left-radius: ${theme.shape.radius.default};
border-top-right-radius: ${theme.shape.radius.default};
`,
receiverDescriptionRow: css`
padding: ${theme.spacing(1)} ${theme.spacing(1.5)};
`,
metadataRow: css`
padding: 0 ${theme.spacing(1.5)} ${theme.spacing(1.5)} ${theme.spacing(1.5)};
border-bottom-left-radius: ${theme.shape.radius.default};
border-bottom-right-radius: ${theme.shape.radius.default};
`,
receiversWrapper: css``,
contactPointWrapper: css({
borderRadius: `${theme.shape.radius.default}`,
border: `solid 1px ${theme.colors.border.weak}`,
borderBottom: 'none',
}),
integrationWrapper: css({
position: 'relative',
background: `${theme.colors.background.primary}`,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
borderBottom: `solid 1px ${theme.colors.border.weak}`,
}),
headerWrapper: css({
background: `${theme.colors.background.secondary}`,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
borderBottom: `solid 1px ${theme.colors.border.weak}`,
borderTopLeftRadius: `${theme.shape.radius.default}`,
borderTopRightRadius: `${theme.shape.radius.default}`,
}),
metadataRow: css({
borderBottomLeftRadius: `${theme.shape.radius.default}`,
borderBottomRightRadius: `${theme.shape.radius.default}`,
}),
});
export default ContactPoints;

@ -0,0 +1,37 @@
import React from 'react';
import { RouteChildrenProps } from 'react-router-dom';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { DuplicateTemplateView } from '../receivers/DuplicateTemplateView';
type Props = RouteChildrenProps<{ name: string }>;
const NewMessageTemplate = ({ match }: Props) => {
const { selectedAlertmanager } = useAlertmanager();
const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager);
const name = match?.params.name;
if (!name) {
return <EntityNotFound entity="Message template" />;
}
if (isLoading && !data) {
return 'loading...';
}
// TODO decent error handling
if (error) {
return String(error);
}
if (!data) {
return null;
}
return <DuplicateTemplateView alertManagerSourceName={selectedAlertmanager!} config={data} templateName={name} />;
};
export default NewMessageTemplate;

@ -0,0 +1,47 @@
import React from 'react';
import { RouteChildrenProps } from 'react-router-dom';
import { Alert } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { EditReceiverView } from '../receivers/EditReceiverView';
type Props = RouteChildrenProps<{ name: string }>;
const EditContactPoint = ({ match }: Props) => {
const { selectedAlertmanager } = useAlertmanager();
const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager);
const contactPointName = match?.params.name;
if (!contactPointName) {
return <EntityNotFound entity="Contact point" />;
}
if (isLoading && !data) {
return 'loading...';
}
if (error) {
return (
<Alert severity="error" title="Failed to fetch contact point">
{String(error)}
</Alert>
);
}
if (!data) {
return null;
}
return (
<EditReceiverView
alertManagerSourceName={selectedAlertmanager!}
config={data}
receiverName={decodeURIComponent(contactPointName)}
/>
);
};
export default EditContactPoint;

@ -0,0 +1,47 @@
import React from 'react';
import { RouteChildrenProps } from 'react-router-dom';
import { Alert } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { EditTemplateView } from '../receivers/EditTemplateView';
type Props = RouteChildrenProps<{ name: string }>;
const EditMessageTemplate = ({ match }: Props) => {
const { selectedAlertmanager } = useAlertmanager();
const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager);
const name = match?.params.name;
if (!name) {
return <EntityNotFound entity="Message template" />;
}
if (isLoading && !data) {
return 'loading...';
}
if (error) {
return (
<Alert severity="error" title="Failed to fetch message template">
{String(error)}
</Alert>
);
}
if (!data) {
return null;
}
return (
<EditTemplateView
alertManagerSourceName={selectedAlertmanager!}
config={data}
templateName={decodeURIComponent(name)}
/>
);
};
export default EditMessageTemplate;

@ -0,0 +1,32 @@
import React from 'react';
import { Alert } from '@grafana/ui';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { GlobalConfigForm } from '../receivers/GlobalConfigForm';
const NewMessageTemplate = () => {
const { selectedAlertmanager } = useAlertmanager();
const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager);
if (isLoading && !data) {
return 'loading...';
}
if (error) {
return (
<Alert severity="error" title="Failed to fetch message template">
{String(error)}
</Alert>
);
}
if (!data) {
return null;
}
return <GlobalConfigForm config={data} alertManagerSourceName={selectedAlertmanager!} />;
};
export default NewMessageTemplate;

@ -0,0 +1,22 @@
import React from 'react';
import { Alert } from '@grafana/ui';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { TemplatesTable } from '../receivers/TemplatesTable';
export const MessageTemplates = () => {
const { selectedAlertmanager } = useAlertmanager();
const { data, error } = useAlertmanagerConfig(selectedAlertmanager);
if (error) {
return <Alert title="Failed to fetch message templates">{String(error)}</Alert>;
}
if (data) {
return <TemplatesTable config={data} alertManagerName={selectedAlertmanager!} />;
}
return null;
};

@ -0,0 +1,33 @@
import React from 'react';
import { RouteChildrenProps } from 'react-router-dom';
import { Alert } from '@grafana/ui';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { NewReceiverView } from '../receivers/NewReceiverView';
const NewContactPoint = (_props: RouteChildrenProps) => {
const { selectedAlertmanager } = useAlertmanager();
const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager);
if (isLoading && !data) {
return 'loading...';
}
if (error) {
return (
<Alert severity="error" title="Failed to fetch contact point">
{String(error)}
</Alert>
);
}
if (!data) {
return null;
}
return <NewReceiverView config={data} alertManagerSourceName={selectedAlertmanager!} />;
};
export default NewContactPoint;

@ -0,0 +1,32 @@
import React from 'react';
import { Alert } from '@grafana/ui';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { NewTemplateView } from '../receivers/NewTemplateView';
const NewMessageTemplate = () => {
const { selectedAlertmanager } = useAlertmanager();
const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager);
if (isLoading && !data) {
return 'loading...';
}
if (error) {
return (
<Alert severity="error" title="Failed to fetch message template">
{String(error)}
</Alert>
);
}
if (!data) {
return null;
}
return <NewTemplateView alertManagerSourceName={selectedAlertmanager!} config={data} />;
};
export default NewMessageTemplate;

@ -0,0 +1,34 @@
{
"template_files": {},
"alertmanager_config": {
"global": {},
"mute_time_intervals": [],
"receivers": [
{
"email_configs": [
{ "require_tls": false, "send_resolved": true, "to": "foo@bar.com" },
{ "require_tls": false, "send_resolved": true, "to": "foo@bar.com" }
],
"name": "mixed",
"webhook_configs": [{ "send_resolved": true, "url": "https://foo.bar/" }]
},
{ "name": "some webhook", "webhook_configs": [{ "send_resolved": true, "url": "https://foo.bar/" }] }
],
"route": {
"continue": false,
"group_by": ["alertname", "grafana_folder"],
"group_interval": "5m",
"group_wait": "30s",
"matchers": [],
"mute_time_intervals": [],
"receiver": "email",
"repeat_interval": "5h",
"routes": [
{
"receiver": "mixed"
}
]
},
"templates": []
}
}

@ -0,0 +1,24 @@
import { rest } from 'msw';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { ReceiversStateDTO } from 'app/types';
import { setupMswServer } from '../../../mockApi';
import alertmanagerMock from './alertmanager.config.mock.json';
import receiversMock from './receivers.mock.json';
export default () => {
const server = setupMswServer();
server.use(
// this endpoint is a grafana built-in alertmanager
rest.get('/api/alertmanager/grafana/config/api/v1/alerts', (_req, res, ctx) =>
res(ctx.json<AlertManagerCortexConfig>(alertmanagerMock))
),
// 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<ReceiversStateDTO[]>(receiversMock))
)
);
};

@ -0,0 +1,23 @@
import { rest } from 'msw';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { setupMswServer } from '../../../mockApi';
import mimirAlertmanagerMock from './alertmanager.mimir.config.mock.json';
// this one emulates a mimir server setup
export const MIMIR_DATASOURCE_UID = 'mimir';
export default () => {
const server = setupMswServer();
server.use(
rest.get(`/api/alertmanager/${MIMIR_DATASOURCE_UID}/config/api/v1/alerts`, (_req, res, ctx) =>
res(ctx.json<AlertManagerCortexConfig>(mimirAlertmanagerMock))
),
rest.get(`/api/datasources/proxy/uid/${MIMIR_DATASOURCE_UID}/api/v1/status/buildinfo`, (_req, res, ctx) =>
res(ctx.status(404))
)
);
};

@ -25,6 +25,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
},
],
"name": "grafana-default-email",
"numberOfPolicies": 0,
},
{
"grafana_managed_receiver_configs": [
@ -48,6 +49,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
},
],
"name": "provisioned-contact-point",
"numberOfPolicies": 0,
},
{
"grafana_managed_receiver_configs": [
@ -70,6 +72,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
},
],
"name": "lotsa-emails",
"numberOfPolicies": 0,
},
{
"grafana_managed_receiver_configs": [
@ -111,6 +114,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
},
],
"name": "Slack with multiple channels",
"numberOfPolicies": 0,
},
],
"error": undefined,

@ -1,10 +1,12 @@
import { renderHook, waitFor } from '@testing-library/react';
import { TestProvider } from 'test/helpers/TestProvider';
import './__mocks__/server';
import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer';
import { useContactPointsWithStatus } from './useContactPoints';
describe('useContactPoints', () => {
setupGrafanaManagedServer();
it('should return contact points with status', async () => {
const { result } = renderHook(() => useContactPointsWithStatus('grafana'), {
wrapper: TestProvider,

@ -1,13 +1,15 @@
import { split } from 'lodash';
import { countBy, split, trim } from 'lodash';
import { ReactNode } from 'react';
import {
AlertManagerCortexConfig,
GrafanaManagedContactPoint,
GrafanaManagedReceiverConfig,
Route,
} from 'app/plugins/datasource/alertmanager/types';
import { NotifierStatus, ReceiversStateDTO } from 'app/types';
import { computeInheritedTree } from '../../utils/notification-policies';
import { extractReceivers } from '../../utils/receivers';
import { RECEIVER_STATUS_KEY } from './useContactPoints';
@ -34,6 +36,10 @@ export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig):
const topicName = receiver.settings['kafkaTopic'];
return topicName;
}
case 'webhook': {
const url = receiver.settings['url'];
return url;
}
default:
return undefined;
}
@ -43,9 +49,10 @@ export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig):
// output: foo+1@bar.com, foo+2@bar.com, +2 more
function summarizeEmailAddresses(addresses: string): string {
const MAX_ADDRESSES_SHOWN = 3;
const SUPPORTED_SEPARATORS = /,|;|\\n/;
const SUPPORTED_SEPARATORS = /,|;|\n+/g;
const emails = addresses.trim().split(SUPPORTED_SEPARATORS).map(trim);
const emails = addresses.trim().split(SUPPORTED_SEPARATORS);
const notShown = emails.length - MAX_ADDRESSES_SHOWN;
const truncatedAddresses = split(addresses, SUPPORTED_SEPARATORS, MAX_ADDRESSES_SHOWN);
@ -64,6 +71,7 @@ export interface ReceiverConfigWithStatus extends GrafanaManagedReceiverConfig {
}
export interface ContactPointWithStatus extends GrafanaManagedContactPoint {
numberOfPolicies: number;
grafana_managed_receiver_configs: ReceiverConfigWithStatus[];
}
@ -78,12 +86,18 @@ export function enhanceContactPointsWithStatus(
): ContactPointWithStatus[] {
const contactPoints = result.alertmanager_config.receivers ?? [];
// compute the entire inherited tree before finding what notification policies are using a particular contact point
const fullyInheritedTree = computeInheritedTree(result?.alertmanager_config?.route ?? {});
const usedContactPoints = getUsedContactPoints(fullyInheritedTree);
const usedContactPointsByName = countBy(usedContactPoints);
return contactPoints.map((contactPoint) => {
const receivers = extractReceivers(contactPoint);
const statusForReceiver = status.find((status) => status.name === contactPoint.name);
return {
...contactPoint,
numberOfPolicies: usedContactPointsByName[contactPoint.name] ?? 0,
grafana_managed_receiver_configs: receivers.map((receiver, index) => ({
...receiver,
[RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index],
@ -91,3 +105,12 @@ export function enhanceContactPointsWithStatus(
};
});
}
export function getUsedContactPoints(route: Route): string[] {
const childrenContactPoints = route.routes?.flatMap((route) => getUsedContactPoints(route)) ?? [];
if (route.receiver) {
return [route.receiver, ...childrenContactPoints];
}
return childrenContactPoints;
}

@ -4,11 +4,12 @@ import { Stack } from '@grafana/experimental';
import { Alert, LinkButton } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerAction } from '../../hooks/useAbilities';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { Authorize } from '../Authorize';
import { ReceiversSection } from './ReceiversSection';
import { ReceiversTable } from './ReceiversTable';
import { TemplatesTable } from './TemplatesTable';
@ -18,26 +19,56 @@ interface Props {
}
export const ReceiversAndTemplatesView = ({ config, alertManagerName }: Props) => {
const isCloud = alertManagerName !== GRAFANA_RULES_SOURCE_NAME;
const isGrafanaManagedAlertmanager = alertManagerName === GRAFANA_RULES_SOURCE_NAME;
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
return (
<Stack direction="column" gap={4}>
<ReceiversTable config={config} alertManagerName={alertManagerName} />
{!isVanillaAM && <TemplatesTable config={config} alertManagerName={alertManagerName} />}
{isCloud && (
{/* Vanilla flavored Alertmanager does not support editing message templates via the UI */}
{!isVanillaAM && <TemplatesView config={config} alertManagerName={alertManagerName} />}
{/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */}
{!isGrafanaManagedAlertmanager && <GlobalConfigAlert alertManagerName={alertManagerName} />}
</Stack>
);
};
export const TemplatesView = ({ config, alertManagerName }: Props) => {
const [createNotificationTemplateSupported, createNotificationTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
return (
<ReceiversSection
title="Notification templates"
description="Create notification templates to customize your notifications."
addButtonLabel="Add template"
addButtonTo={makeAMLink('/alerting/notifications/templates/new', alertManagerName)}
showButton={createNotificationTemplateSupported && createNotificationTemplateAllowed}
>
<TemplatesTable config={config} alertManagerName={alertManagerName} />
</ReceiversSection>
);
};
interface GlobalConfigAlertProps {
alertManagerName: string;
}
export const GlobalConfigAlert = ({ alertManagerName }: GlobalConfigAlertProps) => {
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
return (
<Authorize actions={[AlertmanagerAction.UpdateExternalConfiguration]}>
<Alert severity="info" title="Global config for contact points">
<p>
For each external Alertmanager you can define global settings, like server addresses, usernames and
password, for all the supported contact points.
For each external Alertmanager you can define global settings, like server addresses, usernames and password,
for all the supported contact points.
</p>
<LinkButton href={makeAMLink('alerting/notifications/global-config', alertManagerName)} variant="secondary">
{isVanillaAM ? 'View global config' : 'Edit global config'}
</LinkButton>
</Alert>
</Authorize>
)}
</Stack>
);
};

@ -511,7 +511,7 @@ function useGetColumns(
];
}
function UnusedContactPointBadge() {
export function UnusedContactPointBadge() {
return (
<Badge
text="Unused"

@ -5,7 +5,7 @@ import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/ty
import { useDispatch } from 'app/types';
import { Authorize } from '../../components/Authorize';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { AlertmanagerAction } from '../../hooks/useAbilities';
import { deleteTemplateAction } from '../../state/actions';
import { getAlertTableStyles } from '../../styles/table';
import { makeAMLink } from '../../utils/misc';
@ -14,7 +14,6 @@ import { DetailsField } from '../DetailsField';
import { ProvisioningBadge } from '../Provisioning';
import { ActionIcon } from '../rules/ActionIcon';
import { ReceiversSection } from './ReceiversSection';
import { TemplateEditor } from './TemplateEditor';
interface Props {
@ -26,9 +25,6 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
const dispatch = useDispatch();
const [expandedTemplates, setExpandedTemplates] = useState<Record<string, boolean>>({});
const tableStyles = useStyles2(getAlertTableStyles);
const [createNotificationTemplateSupported, createNotificationTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
const templateRows = useMemo(() => {
const templates = Object.entries(config.template_files);
@ -49,13 +45,7 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
};
return (
<ReceiversSection
title="Notification templates"
description="Create notification templates to customize your notifications."
addButtonLabel="Add template"
addButtonTo={makeAMLink('/alerting/notifications/templates/new', alertManagerName)}
showButton={createNotificationTemplateSupported && createNotificationTemplateAllowed}
>
<>
<table className={tableStyles.table} data-testid="templates-table">
<colgroup>
<col className={tableStyles.colExpand} />
@ -177,6 +167,6 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
onDismiss={() => setTemplateToDelete(undefined)}
/>
)}
</ReceiversSection>
</>
);
};

@ -4,6 +4,7 @@ import { Alert } from '@grafana/ui';
import { AlertManagerCortexConfig, Receiver } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { alertmanagerApi } from '../../../api/alertmanagerApi';
import { updateAlertManagerConfigAction } from '../../../state/actions';
import { CloudChannelValues, ReceiverFormValues, CloudChannelMap } from '../../../types/receiver-form';
import { cloudNotifierTypes } from '../../../utils/cloud-alertmanager-notifier-types';
@ -57,7 +58,9 @@ export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }:
successMessage: existing ? 'Contact point updated.' : 'Contact point created.',
redirectPath: '/alerting/notifications',
})
);
).then(() => {
dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration']));
});
};
const takenReceiverNames = useMemo(

@ -86,7 +86,9 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }
successMessage: existing ? 'Contact point updated.' : 'Contact point created',
redirectPath: '/alerting/notifications',
})
);
).then(() => {
dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration']));
});
};
const onTestChannel = (values: GrafanaChannelValues) => {

@ -1,6 +1,7 @@
import { countBy } from 'lodash';
import { AlertmanagerConfig, Route } from '../../../../../plugins/datasource/alertmanager/types';
import { AlertmanagerConfig } from '../../../../../plugins/datasource/alertmanager/types';
import { getUsedContactPoints } from '../contact-points/utils';
export interface ContactPointConfigHealth {
matchingRoutes: number;
@ -32,12 +33,3 @@ export function useAlertmanagerConfigHealth(config: AlertmanagerConfig): Alertma
return configHealth;
}
function getUsedContactPoints(route: Route): string[] {
const childrenContactPoints = route.routes?.flatMap((route) => getUsedContactPoints(route)) ?? [];
if (route.receiver) {
return [route.receiver, ...childrenContactPoints];
}
return childrenContactPoints;
}

@ -10,4 +10,6 @@ export const INTEGRATION_ICONS: Record<string, IconName> = {
slack: 'slack',
teams: 'microsoft',
telegram: 'telegram-alt',
webhook: 'link',
sns: 'amazon',
};

Loading…
Cancel
Save