Alerting: Contact points v2 part 3 (#72444)

This commit is contained in:
Gilles De Mey 2023-09-26 10:44:18 +02:00 committed by GitHub
parent 440f9a6ffb
commit 5a1580c659
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 916 additions and 228 deletions

View File

@ -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"],

View File

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

View File

@ -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>

View File

@ -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);

View File

@ -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} />

View File

@ -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>

View File

@ -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,9 +50,9 @@ 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();
@ -51,13 +60,53 @@ describe('ContactPoints', () => {
});
});
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} />);
const moreActions = screen.getByTestId('more-actions');
const editAction = screen.getByTestId('edit-action');
expect(moreActions).toHaveProperty('disabled', true);
expect(editAction).toHaveProperty('disabled', true);
it('should disable edit button', async () => {
render(<ContactPoint name={'my-contact-point'} disabled={true} receivers={[]} onDelete={noop} />, {
wrapper,
});
it('should disabled buttons when provisioned', async () => {
render(<ContactPoint name={'my-contact-point'} provisioned={true} receivers={[]} onDelete={noop} />);
const moreActions = screen.getByRole('button', { name: 'more-actions' });
expect(moreActions).not.toBeDisabled();
const editAction = screen.getByTestId('edit-action');
expect(editAction).toHaveAttribute('aria-disabled', 'true');
});
it('should disable buttons when provisioned', async () => {
render(<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();
expect(moreActions).toHaveProperty('disabled', true);
expect(editAction).toHaveProperty('disabled', true);
const viewAction = screen.getByRole('link', { name: /view/i });
expect(viewAction).toBeInTheDocument();
const moreActions = screen.getByRole('button', { name: 'more-actions' });
expect(moreActions).not.toBeDisabled();
await userEvent.click(moreActions);
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
expect(deleteButton).toBeDisabled();
});
it('should disable delete when contact point is linked to at least one notification policy', async () => {
render(<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>
);

View File

@ -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 disableActions = disabled || provisioned;
const isReferencedByPolicies = policies > 0;
const isGranaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
// 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};
contactPointWrapper: css({
borderRadius: `${theme.shape.radius.default}`,
border: `solid 1px ${theme.colors.border.weak}`,
borderBottom: 'none',
}),
integrationWrapper: css({
position: 'relative',
border-bottom: solid 1px ${theme.colors.border.weak};
`,
headerWrapper: css`
padding: ${theme.spacing(1)} ${theme.spacing(1.5)};
background: `${theme.colors.background.primary}`,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
background: ${theme.colors.background.secondary};
borderBottom: `solid 1px ${theme.colors.border.weak}`,
}),
headerWrapper: css({
background: `${theme.colors.background.secondary}`,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
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``,
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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
};

View File

@ -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;

View File

@ -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;

View File

@ -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": []
}
}

View File

@ -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))
)
);
};

View File

@ -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))
)
);
};

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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>
);
};

View File

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

View File

@ -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>
</>
);
};

View File

@ -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(

View File

@ -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) => {

View File

@ -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;
}

View File

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