mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 18:32:23 +08:00
Alerting: Contact points v2 part 3 (#72444)
This commit is contained in:
@ -2108,13 +2108,7 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||||
],
|
],
|
||||||
"public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx:5381": [
|
"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, "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"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/alerting/unified/components/export/FileExportPreview.tsx:5381": [
|
"public/app/features/alerting/unified/components/export/FileExportPreview.tsx:5381": [
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||||
|
@ -6,6 +6,7 @@ export const availableIconsIndex = {
|
|||||||
okta: true,
|
okta: true,
|
||||||
discord: true,
|
discord: true,
|
||||||
hipchat: true,
|
hipchat: true,
|
||||||
|
amazon: true,
|
||||||
'google-hangouts-alt': true,
|
'google-hangouts-alt': true,
|
||||||
pagerduty: true,
|
pagerduty: true,
|
||||||
line: 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
|
// When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632
|
||||||
const button = (
|
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} />}
|
{icon && <Icon name={icon} size={size} className={styles.icon} />}
|
||||||
{children && <span className={styles.content}>{children}</span>}
|
{children && <span className={styles.content}>{children}</span>}
|
||||||
</a>
|
</a>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { forwardRef, Ref } from 'react';
|
||||||
|
|
||||||
interface ConditionalWrapProps {
|
interface ConditionalWrapProps {
|
||||||
shouldWrap: boolean;
|
shouldWrap: boolean;
|
||||||
@ -6,7 +6,8 @@ interface ConditionalWrapProps {
|
|||||||
wrap: (children: JSX.Element) => JSX.Element;
|
wrap: (children: JSX.Element) => JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConditionalWrap = ({ shouldWrap, children, wrap }: ConditionalWrapProps): JSX.Element =>
|
function ConditionalWrap({ children, shouldWrap, wrap }: ConditionalWrapProps, _ref: Ref<HTMLElement>) {
|
||||||
shouldWrap ? React.cloneElement(wrap(children)) : children;
|
return shouldWrap ? React.cloneElement(wrap(children)) : children;
|
||||||
|
}
|
||||||
|
|
||||||
export default ConditionalWrap;
|
export default forwardRef(ConditionalWrap);
|
||||||
|
@ -1,19 +1,42 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Disable, Enable } from 'react-enable';
|
import { Disable, Enable } from 'react-enable';
|
||||||
|
import { Route, Switch } from 'react-router-dom';
|
||||||
|
|
||||||
import { withErrorBoundary } from '@grafana/ui';
|
import { withErrorBoundary } from '@grafana/ui';
|
||||||
const ContactPointsV1 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v1'));
|
const ContactPointsV1 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v1'));
|
||||||
const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints.v2'));
|
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 { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
|
||||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { AlertingFeature } from './features';
|
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 => (
|
const ContactPoints = (props: GrafanaRouteComponentProps): JSX.Element => (
|
||||||
<AlertmanagerPageWrapper pageId="receivers" accessType="notification">
|
<AlertmanagerPageWrapper pageId="receivers" accessType="notification">
|
||||||
<Enable feature={AlertingFeature.ContactPointsV2}>
|
<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>
|
</Enable>
|
||||||
<Disable feature={AlertingFeature.ContactPointsV2}>
|
<Disable feature={AlertingFeature.ContactPointsV2}>
|
||||||
<ContactPointsV1 {...props} />
|
<ContactPointsV1 {...props} />
|
||||||
|
@ -21,9 +21,9 @@ const MetaText = ({ children, icon, color = 'secondary', ...rest }: Props) => {
|
|||||||
// allow passing ARIA and data- attributes
|
// allow passing ARIA and data- attributes
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Text color={color}>
|
<Text variant="bodySmall" color={color}>
|
||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
{icon && <Icon name={icon} />}
|
{icon && <Icon size="sm" name={icon} />}
|
||||||
{children}
|
{children}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
|
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
import React from 'react';
|
import React, { PropsWithChildren } from 'react';
|
||||||
import { TestProvider } from 'test/helpers/TestProvider';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
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 { AlertmanagerProvider } from '../../state/AlertmanagerContext';
|
||||||
|
import { setupDataSources } from '../../testSetup/datasources';
|
||||||
|
import { DataSourceType } from '../../utils/datasource';
|
||||||
|
|
||||||
import ContactPoints, { ContactPoint } from './ContactPoints.v2';
|
import ContactPoints, { ContactPoint } from './ContactPoints.v2';
|
||||||
|
import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer';
|
||||||
import './__mocks__/server';
|
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.
|
* There are lots of ways in which we test our pages and components. Here's my opinionated approach to testing them.
|
||||||
@ -28,26 +31,70 @@ import './__mocks__/server';
|
|||||||
* if those have any logic or data structure transformations in them.
|
* if those have any logic or data structure transformations in them.
|
||||||
*/
|
*/
|
||||||
describe('ContactPoints', () => {
|
describe('ContactPoints', () => {
|
||||||
beforeAll(() => {
|
describe('Grafana managed alertmanager', () => {
|
||||||
disableRBAC();
|
setupGrafanaManagedServer();
|
||||||
});
|
|
||||||
|
|
||||||
it('should show / hide loading states', async () => {
|
beforeAll(() => {
|
||||||
render(
|
grantUserPermissions([
|
||||||
<AlertmanagerProvider accessType={'notification'}>
|
AccessControlAction.AlertingNotificationsRead,
|
||||||
<ContactPoints />
|
AccessControlAction.AlertingNotificationsWrite,
|
||||||
</AlertmanagerProvider>,
|
]);
|
||||||
{ wrapper: TestProvider }
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
|
||||||
await expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
||||||
await waitForElementToBeRemoved(screen.getByText('Loading...'));
|
|
||||||
await expect(screen.queryByTestId(selectors.components.Alert.alertV2('error'))).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('grafana-default-email')).toBeInTheDocument();
|
it('should show / hide loading states', async () => {
|
||||||
expect(screen.getAllByTestId('contact-point')).toHaveLength(4);
|
render(
|
||||||
|
<AlertmanagerProvider accessType={'notification'}>
|
||||||
|
<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('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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -55,9 +102,11 @@ describe('ContactPoint', () => {
|
|||||||
it('should call delete when clicked and not disabled', async () => {
|
it('should call delete when clicked and not disabled', async () => {
|
||||||
const onDelete = jest.fn();
|
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);
|
await userEvent.click(moreActions);
|
||||||
|
|
||||||
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
|
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
|
||||||
@ -66,25 +115,56 @@ describe('ContactPoint', () => {
|
|||||||
expect(onDelete).toHaveBeenCalledWith('my-contact-point');
|
expect(onDelete).toHaveBeenCalledWith('my-contact-point');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should disabled buttons', async () => {
|
it('should disable edit button', async () => {
|
||||||
render(<ContactPoint name={'my-contact-point'} disabled={true} receivers={[]} onDelete={noop} />);
|
render(<ContactPoint name={'my-contact-point'} disabled={true} receivers={[]} onDelete={noop} />, {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moreActions = screen.getByRole('button', { name: 'more-actions' });
|
||||||
|
expect(moreActions).not.toBeDisabled();
|
||||||
|
|
||||||
const moreActions = screen.getByTestId('more-actions');
|
|
||||||
const editAction = screen.getByTestId('edit-action');
|
const editAction = screen.getByTestId('edit-action');
|
||||||
|
expect(editAction).toHaveAttribute('aria-disabled', 'true');
|
||||||
expect(moreActions).toHaveProperty('disabled', true);
|
|
||||||
expect(editAction).toHaveProperty('disabled', true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should disabled buttons when provisioned', async () => {
|
it('should disable buttons when provisioned', async () => {
|
||||||
render(<ContactPoint name={'my-contact-point'} provisioned={true} receivers={[]} onDelete={noop} />);
|
render(<ContactPoint name={'my-contact-point'} provisioned={true} receivers={[]} onDelete={noop} />, {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByText(/provisioned/i)).toBeInTheDocument();
|
expect(screen.getByText(/provisioned/i)).toBeInTheDocument();
|
||||||
|
|
||||||
const moreActions = screen.getByTestId('more-actions');
|
const editAction = screen.queryByTestId('edit-action');
|
||||||
const editAction = screen.getByTestId('edit-action');
|
expect(editAction).not.toBeInTheDocument();
|
||||||
|
|
||||||
expect(moreActions).toHaveProperty('disabled', true);
|
const viewAction = screen.getByRole('link', { name: /view/i });
|
||||||
expect(editAction).toHaveProperty('disabled', true);
|
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>
|
||||||
|
);
|
||||||
|
@ -1,72 +1,205 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { SerializedError } from '@reduxjs/toolkit';
|
import { SerializedError } from '@reduxjs/toolkit';
|
||||||
import { uniqueId, upperFirst } from 'lodash';
|
import { groupBy, size, uniqueId, upperFirst } from 'lodash';
|
||||||
import React, { ReactNode } from 'react';
|
import pluralize from 'pluralize';
|
||||||
|
import React, { ReactNode, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { dateTime, GrafanaTheme2 } from '@grafana/data';
|
import { dateTime, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Stack } from '@grafana/experimental';
|
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 ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
|
||||||
|
import { isOrgAdmin } from 'app/features/plugins/admin/permissions';
|
||||||
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
|
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
|
||||||
|
import { GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting';
|
import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting';
|
||||||
|
|
||||||
|
import { usePagination } from '../../hooks/usePagination';
|
||||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||||
import { INTEGRATION_ICONS } from '../../types/contact-points';
|
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 { MetaText } from '../MetaText';
|
||||||
import { ProvisioningBadge } from '../Provisioning';
|
import { ProvisioningBadge } from '../Provisioning';
|
||||||
import { Spacer } from '../Spacer';
|
import { Spacer } from '../Spacer';
|
||||||
import { Strong } from '../Strong';
|
import { Strong } from '../Strong';
|
||||||
|
import { GlobalConfigAlert } from '../receivers/ReceiversAndTemplatesView';
|
||||||
|
import { UnusedContactPointBadge } from '../receivers/ReceiversTable';
|
||||||
|
|
||||||
|
import { MessageTemplates } from './MessageTemplates';
|
||||||
import { useDeleteContactPointModal } from './Modals';
|
import { useDeleteContactPointModal } from './Modals';
|
||||||
import { RECEIVER_STATUS_KEY, useContactPointsWithStatus, useDeleteContactPoint } from './useContactPoints';
|
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 ContactPoints = () => {
|
||||||
const { selectedAlertmanager } = useAlertmanager();
|
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 { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!);
|
||||||
|
|
||||||
const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading);
|
const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading);
|
||||||
|
|
||||||
|
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
|
||||||
|
const showingMessageTemplates = activeTab === ActiveTab.MessageTemplates;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
// TODO fix this type casting, when error comes from "getContactPointsStatus" it probably won't be a SerializedError
|
// 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>;
|
return <Alert title="Failed to fetch contact points">{(error as SerializedError).message}</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
|
||||||
return <LoadingPlaceholder text={'Loading...'} />;
|
const isVanillaAlertmanager = isVanillaPrometheusAlertManagerDataSource(selectedAlertmanager!);
|
||||||
}
|
const permissions = getNotificationsPermissions(selectedAlertmanager!);
|
||||||
|
|
||||||
|
const allowedToAddContactPoint = contextSrv.hasPermission(permissions.create);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
{contactPoints.map((contactPoint) => {
|
<TabsBar>
|
||||||
const contactPointKey = selectedAlertmanager + contactPoint.name;
|
<Tab
|
||||||
const provisioned = isProvisioned(contactPoint);
|
label="Contact Points"
|
||||||
const disabled = updateAlertmanagerState.isLoading;
|
active={showingContactPoints}
|
||||||
|
counter={contactPoints.length}
|
||||||
return (
|
onChangeTab={() => setActiveTab(ActiveTab.ContactPoints)}
|
||||||
<ContactPoint
|
/>
|
||||||
key={contactPointKey}
|
<Tab
|
||||||
name={contactPoint.name}
|
label="Message Templates"
|
||||||
disabled={disabled}
|
active={showingMessageTemplates}
|
||||||
onDelete={showDeleteModal}
|
onChangeTab={() => setActiveTab(ActiveTab.MessageTemplates)}
|
||||||
receivers={contactPoint.grafana_managed_receiver_configs}
|
/>
|
||||||
provisioned={provisioned}
|
<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>
|
</Stack>
|
||||||
{DeleteModal}
|
{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 policies = contactPoint.numberOfPolicies;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContactPoint
|
||||||
|
key={`${contactPoint.name}-${index}`}
|
||||||
|
name={contactPoint.name}
|
||||||
|
disabled={disabled}
|
||||||
|
onDelete={onDelete}
|
||||||
|
receivers={contactPoint.grafana_managed_receiver_configs}
|
||||||
|
provisioned={provisioned}
|
||||||
|
policies={policies}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Pagination currentPage={page} numberOfPages={numberOfPages} onNavigate={onPageChange} hideWhenSinglePage />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface ContactPointProps {
|
interface ContactPointProps {
|
||||||
name: string;
|
name: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
provisioned?: boolean;
|
provisioned?: boolean;
|
||||||
receivers: ReceiverConfigWithStatus[];
|
receivers: ReceiverConfigWithStatus[];
|
||||||
|
policies?: number;
|
||||||
onDelete: (name: string) => void;
|
onDelete: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,36 +208,46 @@ export const ContactPoint = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
provisioned = false,
|
provisioned = false,
|
||||||
receivers,
|
receivers,
|
||||||
|
policies = 0,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ContactPointProps) => {
|
}: ContactPointProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
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 (
|
return (
|
||||||
<div className={styles.contactPointWrapper} data-testid="contact-point">
|
<div className={styles.contactPointWrapper} data-testid="contact-point">
|
||||||
<Stack direction="column" gap={0}>
|
<Stack direction="column" gap={0}>
|
||||||
<ContactPointHeader
|
<ContactPointHeader
|
||||||
name={name}
|
name={name}
|
||||||
policies={[]}
|
policies={policies}
|
||||||
provisioned={provisioned}
|
provisioned={provisioned}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
<div className={styles.receiversWrapper}>
|
{showFullMetadata ? (
|
||||||
{receivers?.map((receiver) => {
|
<div>
|
||||||
const diagnostics = receiver[RECEIVER_STATUS_KEY];
|
{receivers?.map((receiver) => {
|
||||||
const sendingResolved = !Boolean(receiver.disableResolveMessage);
|
const diagnostics = receiver[RECEIVER_STATUS_KEY];
|
||||||
|
const sendingResolved = !Boolean(receiver.disableResolveMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContactPointReceiver
|
<ContactPointReceiver
|
||||||
key={uniqueId()}
|
key={uniqueId()}
|
||||||
type={receiver.type}
|
type={receiver.type}
|
||||||
description={getReceiverDescription(receiver)}
|
description={getReceiverDescription(receiver)}
|
||||||
diagnostics={diagnostics}
|
diagnostics={diagnostics}
|
||||||
sendingResolved={sendingResolved}
|
sendingResolved={sendingResolved}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<ContactPointReceiverSummary receivers={receivers} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -114,64 +257,97 @@ interface ContactPointHeaderProps {
|
|||||||
name: string;
|
name: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
provisioned?: boolean;
|
provisioned?: boolean;
|
||||||
policies?: string[]; // some array of policies that refer to this contact point
|
policies?: number;
|
||||||
onDelete: (name: string) => void;
|
onDelete: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
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 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 (
|
return (
|
||||||
<div className={styles.headerWrapper}>
|
<div className={styles.headerWrapper}>
|
||||||
<Stack direction="row" alignItems="center" gap={1}>
|
<Stack direction="row" alignItems="center" gap={1}>
|
||||||
<Stack alignItems="center" gap={1}>
|
<Stack alignItems="center" gap={1}>
|
||||||
<Text variant="body">{name}</Text>
|
<Text variant="body" weight="medium">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{policies.length > 0 ? (
|
{isReferencedByPolicies ? (
|
||||||
<MetaText>
|
<MetaText>
|
||||||
{/* TODO make this a link to the notification policies page with the filter applied */}
|
<Link to={createUrl('/alerting/routes', { contactPoint: name })}>
|
||||||
is used by <Strong>{policies.length}</Strong> notification policies
|
is used by <Strong>{policies}</Strong> {pluralize('notification policy', policies)}
|
||||||
|
</Link>
|
||||||
</MetaText>
|
</MetaText>
|
||||||
) : (
|
) : (
|
||||||
<MetaText>is not used in any policy</MetaText>
|
<UnusedContactPointBadge />
|
||||||
)}
|
)}
|
||||||
{provisioned && <ProvisioningBadge />}
|
{provisioned && <ProvisioningBadge />}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<ConditionalWrap
|
<LinkButton
|
||||||
shouldWrap={provisioned}
|
tooltipPlacement="top"
|
||||||
wrap={(children) => (
|
tooltip={provisioned ? 'Provisioned contact points cannot be edited in the UI' : undefined}
|
||||||
<Tooltip content="Provisioned items cannot be edited in the UI" placement="top">
|
variant="secondary"
|
||||||
{children}
|
size="sm"
|
||||||
</Tooltip>
|
icon={provisioned ? 'document-info' : 'edit'}
|
||||||
)}
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`${provisioned ? 'view' : 'edit'}-action`}
|
||||||
|
data-testid={`${provisioned ? 'view' : 'edit'}-action`}
|
||||||
|
href={`/alerting/notifications/receivers/${encodeURIComponent(name)}/edit`}
|
||||||
>
|
>
|
||||||
<Button
|
{provisioned ? 'View' : 'Edit'}
|
||||||
variant="secondary"
|
</LinkButton>
|
||||||
size="sm"
|
{/* TODO probably want to split this off since there's lots of RBAC involved here */}
|
||||||
icon="edit"
|
|
||||||
type="button"
|
|
||||||
disabled={disableActions}
|
|
||||||
aria-label="edit-action"
|
|
||||||
data-testid="edit-action"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</ConditionalWrap>
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
overlay={
|
overlay={
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Item label="Export" icon="download-alt" />
|
{canExport && (
|
||||||
<Menu.Divider />
|
<>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
label="Delete"
|
icon="download-alt"
|
||||||
icon="trash-alt"
|
label={isOrgAdmin() ? 'Export' : 'Export redacted'}
|
||||||
destructive
|
disabled={!allowedToExport}
|
||||||
disabled={disableActions}
|
url={createUrl(`/api/v1/provisioning/contact-points/export/`, {
|
||||||
onClick={() => onDelete(name)}
|
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={disabled || provisioned || policies > 0}
|
||||||
|
onClick={() => onDelete(name)}
|
||||||
|
/>
|
||||||
|
</ConditionalWrap>
|
||||||
</Menu>
|
</Menu>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -182,7 +358,6 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="more-actions"
|
aria-label="more-actions"
|
||||||
data-testid="more-actions"
|
data-testid="more-actions"
|
||||||
disabled={disableActions}
|
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Stack>
|
</Stack>
|
||||||
@ -203,27 +378,26 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => {
|
|||||||
|
|
||||||
const iconName = INTEGRATION_ICONS[type];
|
const iconName = INTEGRATION_ICONS[type];
|
||||||
const hasMetadata = diagnostics !== undefined;
|
const hasMetadata = diagnostics !== undefined;
|
||||||
|
|
||||||
// TODO get the actual name of the type from /ngalert if grafanaManaged AM
|
// TODO get the actual name of the type from /ngalert if grafanaManaged AM
|
||||||
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
|
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.integrationWrapper}>
|
<div className={styles.integrationWrapper}>
|
||||||
<Stack direction="column" gap={0}>
|
<Stack direction="column" gap={0.5}>
|
||||||
<div className={styles.receiverDescriptionRow}>
|
<Stack direction="row" alignItems="center" gap={1}>
|
||||||
<Stack direction="row" alignItems="center" gap={1}>
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
{iconName && <Icon name={iconName} />}
|
||||||
{iconName && <Icon name={iconName} />}
|
<Text variant="body" color="primary">
|
||||||
<Text variant="body" color="primary">
|
{receiverName}
|
||||||
{receiverName}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
{description && (
|
|
||||||
<Text variant="bodySmall" color="secondary">
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
{description && (
|
||||||
|
<Text variant="bodySmall" color="secondary">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
{hasMetadata && <ContactPointReceiverMetadataRow diagnostics={diagnostics} sendingResolved={sendingResolved} />}
|
{hasMetadata && <ContactPointReceiverMetadataRow diagnostics={diagnostics} sendingResolved={sendingResolved} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
@ -235,8 +409,47 @@ interface ContactPointReceiverMetadata {
|
|||||||
diagnostics: NotifierStatus;
|
diagnostics: NotifierStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) => {
|
type ContactPointReceiverSummaryProps = {
|
||||||
const { diagnostics, sendingResolved } = props;
|
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 styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const failedToSend = Boolean(diagnostics.lastNotifyAttemptError);
|
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 */}
|
{/* this is shown when the last delivery failed – we don't show any additional metadata */}
|
||||||
{failedToSend ? (
|
{failedToSend ? (
|
||||||
<>
|
<>
|
||||||
{/* TODO we might need an error variant for MetaText, dito for success */}
|
<MetaText color="error" icon="exclamation-circle">
|
||||||
<Text color="error" variant="bodySmall" weight="bold">
|
<Tooltip content={diagnostics.lastNotifyAttemptError!}>
|
||||||
<Stack direction="row" alignItems={'center'} gap={0.5}>
|
<span>Last delivery attempt failed</span>
|
||||||
<Tooltip content={diagnostics.lastNotifyAttemptError!}>
|
</Tooltip>
|
||||||
<span>
|
</MetaText>
|
||||||
<Icon name="exclamation-circle" /> Last delivery attempt failed
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</Text>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -295,36 +503,31 @@ const ContactPointReceiverMetadataRow = (props: ContactPointReceiverMetadata) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
contactPointWrapper: css`
|
contactPointWrapper: css({
|
||||||
border-radius: ${theme.shape.radius.default};
|
borderRadius: `${theme.shape.radius.default}`,
|
||||||
border: solid 1px ${theme.colors.border.weak};
|
border: `solid 1px ${theme.colors.border.weak}`,
|
||||||
border-bottom: none;
|
borderBottom: 'none',
|
||||||
`,
|
}),
|
||||||
integrationWrapper: css`
|
integrationWrapper: css({
|
||||||
position: relative;
|
position: 'relative',
|
||||||
background: ${theme.colors.background.primary};
|
|
||||||
|
|
||||||
border-bottom: solid 1px ${theme.colors.border.weak};
|
background: `${theme.colors.background.primary}`,
|
||||||
`,
|
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
|
||||||
headerWrapper: css`
|
|
||||||
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};
|
borderBottom: `solid 1px ${theme.colors.border.weak}`,
|
||||||
border-top-left-radius: ${theme.shape.radius.default};
|
borderTopLeftRadius: `${theme.shape.radius.default}`,
|
||||||
border-top-right-radius: ${theme.shape.radius.default};
|
borderTopRightRadius: `${theme.shape.radius.default}`,
|
||||||
`,
|
}),
|
||||||
receiverDescriptionRow: css`
|
metadataRow: css({
|
||||||
padding: ${theme.spacing(1)} ${theme.spacing(1.5)};
|
borderBottomLeftRadius: `${theme.shape.radius.default}`,
|
||||||
`,
|
borderBottomRightRadius: `${theme.shape.radius.default}`,
|
||||||
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``,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ContactPoints;
|
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",
|
"name": "grafana-default-email",
|
||||||
|
"numberOfPolicies": 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"grafana_managed_receiver_configs": [
|
"grafana_managed_receiver_configs": [
|
||||||
@ -48,6 +49,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"name": "provisioned-contact-point",
|
"name": "provisioned-contact-point",
|
||||||
|
"numberOfPolicies": 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"grafana_managed_receiver_configs": [
|
"grafana_managed_receiver_configs": [
|
||||||
@ -70,6 +72,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"name": "lotsa-emails",
|
"name": "lotsa-emails",
|
||||||
|
"numberOfPolicies": 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"grafana_managed_receiver_configs": [
|
"grafana_managed_receiver_configs": [
|
||||||
@ -111,6 +114,7 @@ exports[`useContactPoints should return contact points with status 1`] = `
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"name": "Slack with multiple channels",
|
"name": "Slack with multiple channels",
|
||||||
|
"numberOfPolicies": 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"error": undefined,
|
"error": undefined,
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { TestProvider } from 'test/helpers/TestProvider';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
import './__mocks__/server';
|
import setupGrafanaManagedServer from './__mocks__/grafanaManagedServer';
|
||||||
import { useContactPointsWithStatus } from './useContactPoints';
|
import { useContactPointsWithStatus } from './useContactPoints';
|
||||||
|
|
||||||
describe('useContactPoints', () => {
|
describe('useContactPoints', () => {
|
||||||
|
setupGrafanaManagedServer();
|
||||||
|
|
||||||
it('should return contact points with status', async () => {
|
it('should return contact points with status', async () => {
|
||||||
const { result } = renderHook(() => useContactPointsWithStatus('grafana'), {
|
const { result } = renderHook(() => useContactPointsWithStatus('grafana'), {
|
||||||
wrapper: TestProvider,
|
wrapper: TestProvider,
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import { split } from 'lodash';
|
import { countBy, split, trim } from 'lodash';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
GrafanaManagedContactPoint,
|
GrafanaManagedContactPoint,
|
||||||
GrafanaManagedReceiverConfig,
|
GrafanaManagedReceiverConfig,
|
||||||
|
Route,
|
||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { NotifierStatus, ReceiversStateDTO } from 'app/types';
|
import { NotifierStatus, ReceiversStateDTO } from 'app/types';
|
||||||
|
|
||||||
|
import { computeInheritedTree } from '../../utils/notification-policies';
|
||||||
import { extractReceivers } from '../../utils/receivers';
|
import { extractReceivers } from '../../utils/receivers';
|
||||||
|
|
||||||
import { RECEIVER_STATUS_KEY } from './useContactPoints';
|
import { RECEIVER_STATUS_KEY } from './useContactPoints';
|
||||||
@ -34,6 +36,10 @@ export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig):
|
|||||||
const topicName = receiver.settings['kafkaTopic'];
|
const topicName = receiver.settings['kafkaTopic'];
|
||||||
return topicName;
|
return topicName;
|
||||||
}
|
}
|
||||||
|
case 'webhook': {
|
||||||
|
const url = receiver.settings['url'];
|
||||||
|
return url;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -43,9 +49,10 @@ export function getReceiverDescription(receiver: GrafanaManagedReceiverConfig):
|
|||||||
// output: foo+1@bar.com, foo+2@bar.com, +2 more
|
// output: foo+1@bar.com, foo+2@bar.com, +2 more
|
||||||
function summarizeEmailAddresses(addresses: string): string {
|
function summarizeEmailAddresses(addresses: string): string {
|
||||||
const MAX_ADDRESSES_SHOWN = 3;
|
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 notShown = emails.length - MAX_ADDRESSES_SHOWN;
|
||||||
|
|
||||||
const truncatedAddresses = split(addresses, SUPPORTED_SEPARATORS, 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 {
|
export interface ContactPointWithStatus extends GrafanaManagedContactPoint {
|
||||||
|
numberOfPolicies: number;
|
||||||
grafana_managed_receiver_configs: ReceiverConfigWithStatus[];
|
grafana_managed_receiver_configs: ReceiverConfigWithStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,12 +86,18 @@ export function enhanceContactPointsWithStatus(
|
|||||||
): ContactPointWithStatus[] {
|
): ContactPointWithStatus[] {
|
||||||
const contactPoints = result.alertmanager_config.receivers ?? [];
|
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) => {
|
return contactPoints.map((contactPoint) => {
|
||||||
const receivers = extractReceivers(contactPoint);
|
const receivers = extractReceivers(contactPoint);
|
||||||
const statusForReceiver = status.find((status) => status.name === contactPoint.name);
|
const statusForReceiver = status.find((status) => status.name === contactPoint.name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...contactPoint,
|
...contactPoint,
|
||||||
|
numberOfPolicies: usedContactPointsByName[contactPoint.name] ?? 0,
|
||||||
grafana_managed_receiver_configs: receivers.map((receiver, index) => ({
|
grafana_managed_receiver_configs: receivers.map((receiver, index) => ({
|
||||||
...receiver,
|
...receiver,
|
||||||
[RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index],
|
[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 { Alert, LinkButton } from '@grafana/ui';
|
||||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
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 { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
|
||||||
import { makeAMLink } from '../../utils/misc';
|
import { makeAMLink } from '../../utils/misc';
|
||||||
import { Authorize } from '../Authorize';
|
import { Authorize } from '../Authorize';
|
||||||
|
|
||||||
|
import { ReceiversSection } from './ReceiversSection';
|
||||||
import { ReceiversTable } from './ReceiversTable';
|
import { ReceiversTable } from './ReceiversTable';
|
||||||
import { TemplatesTable } from './TemplatesTable';
|
import { TemplatesTable } from './TemplatesTable';
|
||||||
|
|
||||||
@ -18,26 +19,56 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ReceiversAndTemplatesView = ({ config, alertManagerName }: 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);
|
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="column" gap={4}>
|
<Stack direction="column" gap={4}>
|
||||||
<ReceiversTable config={config} alertManagerName={alertManagerName} />
|
<ReceiversTable config={config} alertManagerName={alertManagerName} />
|
||||||
{!isVanillaAM && <TemplatesTable config={config} alertManagerName={alertManagerName} />}
|
{/* Vanilla flavored Alertmanager does not support editing message templates via the UI */}
|
||||||
{isCloud && (
|
{!isVanillaAM && <TemplatesView config={config} alertManagerName={alertManagerName} />}
|
||||||
<Authorize actions={[AlertmanagerAction.UpdateExternalConfiguration]}>
|
{/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */}
|
||||||
<Alert severity="info" title="Global config for contact points">
|
{!isGrafanaManagedAlertmanager && <GlobalConfigAlert alertManagerName={alertManagerName} />}
|
||||||
<p>
|
|
||||||
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>
|
</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.
|
||||||
|
</p>
|
||||||
|
<LinkButton href={makeAMLink('alerting/notifications/global-config', alertManagerName)} variant="secondary">
|
||||||
|
{isVanillaAM ? 'View global config' : 'Edit global config'}
|
||||||
|
</LinkButton>
|
||||||
|
</Alert>
|
||||||
|
</Authorize>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -511,7 +511,7 @@ function useGetColumns(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function UnusedContactPointBadge() {
|
export function UnusedContactPointBadge() {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
text="Unused"
|
text="Unused"
|
||||||
|
@ -5,7 +5,7 @@ import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/ty
|
|||||||
import { useDispatch } from 'app/types';
|
import { useDispatch } from 'app/types';
|
||||||
|
|
||||||
import { Authorize } from '../../components/Authorize';
|
import { Authorize } from '../../components/Authorize';
|
||||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
import { AlertmanagerAction } from '../../hooks/useAbilities';
|
||||||
import { deleteTemplateAction } from '../../state/actions';
|
import { deleteTemplateAction } from '../../state/actions';
|
||||||
import { getAlertTableStyles } from '../../styles/table';
|
import { getAlertTableStyles } from '../../styles/table';
|
||||||
import { makeAMLink } from '../../utils/misc';
|
import { makeAMLink } from '../../utils/misc';
|
||||||
@ -14,7 +14,6 @@ import { DetailsField } from '../DetailsField';
|
|||||||
import { ProvisioningBadge } from '../Provisioning';
|
import { ProvisioningBadge } from '../Provisioning';
|
||||||
import { ActionIcon } from '../rules/ActionIcon';
|
import { ActionIcon } from '../rules/ActionIcon';
|
||||||
|
|
||||||
import { ReceiversSection } from './ReceiversSection';
|
|
||||||
import { TemplateEditor } from './TemplateEditor';
|
import { TemplateEditor } from './TemplateEditor';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -26,9 +25,6 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [expandedTemplates, setExpandedTemplates] = useState<Record<string, boolean>>({});
|
const [expandedTemplates, setExpandedTemplates] = useState<Record<string, boolean>>({});
|
||||||
const tableStyles = useStyles2(getAlertTableStyles);
|
const tableStyles = useStyles2(getAlertTableStyles);
|
||||||
const [createNotificationTemplateSupported, createNotificationTemplateAllowed] = useAlertmanagerAbility(
|
|
||||||
AlertmanagerAction.CreateNotificationTemplate
|
|
||||||
);
|
|
||||||
|
|
||||||
const templateRows = useMemo(() => {
|
const templateRows = useMemo(() => {
|
||||||
const templates = Object.entries(config.template_files);
|
const templates = Object.entries(config.template_files);
|
||||||
@ -49,13 +45,7 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<table className={tableStyles.table} data-testid="templates-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className={tableStyles.colExpand} />
|
<col className={tableStyles.colExpand} />
|
||||||
@ -177,6 +167,6 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
|
|||||||
onDismiss={() => setTemplateToDelete(undefined)}
|
onDismiss={() => setTemplateToDelete(undefined)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ReceiversSection>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,7 @@ import { Alert } from '@grafana/ui';
|
|||||||
import { AlertManagerCortexConfig, Receiver } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertManagerCortexConfig, Receiver } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { useDispatch } from 'app/types';
|
import { useDispatch } from 'app/types';
|
||||||
|
|
||||||
|
import { alertmanagerApi } from '../../../api/alertmanagerApi';
|
||||||
import { updateAlertManagerConfigAction } from '../../../state/actions';
|
import { updateAlertManagerConfigAction } from '../../../state/actions';
|
||||||
import { CloudChannelValues, ReceiverFormValues, CloudChannelMap } from '../../../types/receiver-form';
|
import { CloudChannelValues, ReceiverFormValues, CloudChannelMap } from '../../../types/receiver-form';
|
||||||
import { cloudNotifierTypes } from '../../../utils/cloud-alertmanager-notifier-types';
|
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.',
|
successMessage: existing ? 'Contact point updated.' : 'Contact point created.',
|
||||||
redirectPath: '/alerting/notifications',
|
redirectPath: '/alerting/notifications',
|
||||||
})
|
})
|
||||||
);
|
).then(() => {
|
||||||
|
dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration']));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const takenReceiverNames = useMemo(
|
const takenReceiverNames = useMemo(
|
||||||
|
@ -86,7 +86,9 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }
|
|||||||
successMessage: existing ? 'Contact point updated.' : 'Contact point created',
|
successMessage: existing ? 'Contact point updated.' : 'Contact point created',
|
||||||
redirectPath: '/alerting/notifications',
|
redirectPath: '/alerting/notifications',
|
||||||
})
|
})
|
||||||
);
|
).then(() => {
|
||||||
|
dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration']));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTestChannel = (values: GrafanaChannelValues) => {
|
const onTestChannel = (values: GrafanaChannelValues) => {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { countBy } from 'lodash';
|
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 {
|
export interface ContactPointConfigHealth {
|
||||||
matchingRoutes: number;
|
matchingRoutes: number;
|
||||||
@ -32,12 +33,3 @@ export function useAlertmanagerConfigHealth(config: AlertmanagerConfig): Alertma
|
|||||||
|
|
||||||
return configHealth;
|
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',
|
slack: 'slack',
|
||||||
teams: 'microsoft',
|
teams: 'microsoft',
|
||||||
telegram: 'telegram-alt',
|
telegram: 'telegram-alt',
|
||||||
|
webhook: 'link',
|
||||||
|
sns: 'amazon',
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user