mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: create/edit notification templates (#33225)
This commit is contained in:
@@ -12,7 +12,7 @@ export interface FieldProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Label for the field */
|
||||
label?: React.ReactNode;
|
||||
/** Description of the field */
|
||||
description?: string;
|
||||
description?: React.ReactNode;
|
||||
/** Indicates if field is in invalid state */
|
||||
invalid?: boolean;
|
||||
/** Indicates if field is in loading state */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UrlQueryMap } from '@grafana/data';
|
||||
import { locationSearchToObject, locationService } from '@grafana/runtime';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-use';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useQueryParams(): [UrlQueryMap, (values: UrlQueryMap, replace?: boolean) => void] {
|
||||
const { search } = useLocation();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Alert, Field, LoadingPlaceholder } from '@grafana/ui';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||
@@ -15,10 +16,17 @@ const AmRoutes: FC = () => {
|
||||
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
|
||||
if (alertManagerSourceName) {
|
||||
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
|
||||
}
|
||||
}, [alertManagerSourceName, dispatch]);
|
||||
|
||||
const { result, loading, error } = amConfigs[alertManagerSourceName] || initialAsyncRequestState;
|
||||
const { result, loading, error } =
|
||||
(alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState;
|
||||
|
||||
if (!alertManagerSourceName) {
|
||||
return <Redirect to="/alerting/routes" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper pageId="am-routes">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Field, Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Redirect, Route, RouteChildrenProps, Switch, useLocation } from 'react-router-dom';
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
||||
import { ReceiversTable } from './components/receivers/ReceiversTable';
|
||||
import { TemplatesTable } from './components/receivers/TemplatesTable';
|
||||
import { EditTemplateView } from './components/receivers/EditTemplateView';
|
||||
import { NewTemplateView } from './components/receivers/NewTemplateView';
|
||||
import { ReceiversAndTemplatesView } from './components/receivers/ReceiversAndTemplatesView';
|
||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||
import { fetchAlertManagerConfigAction, fetchGrafanaNotifiersAction } from './state/actions';
|
||||
@@ -15,12 +17,22 @@ const Receivers: FC = () => {
|
||||
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const config = useUnifiedAlertingSelector((state) => state.amConfigs);
|
||||
const location = useLocation();
|
||||
const isRoot = location.pathname.endsWith('/alerting/notifications');
|
||||
|
||||
const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs);
|
||||
|
||||
const { result: config, loading, error } =
|
||||
(alertManagerSourceName && configRequests[alertManagerSourceName]) || initialAsyncRequestState;
|
||||
const receiverTypes = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
|
||||
|
||||
const shouldLoadConfig = isRoot || !config;
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
|
||||
}, [alertManagerSourceName, dispatch]);
|
||||
if (alertManagerSourceName && shouldLoadConfig) {
|
||||
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
|
||||
}
|
||||
}, [alertManagerSourceName, dispatch, shouldLoadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && !(receiverTypes.result || receiverTypes.loading)) {
|
||||
@@ -28,11 +40,15 @@ const Receivers: FC = () => {
|
||||
}
|
||||
}, [alertManagerSourceName, dispatch, receiverTypes]);
|
||||
|
||||
const { result, loading, error } = config[alertManagerSourceName] || initialAsyncRequestState;
|
||||
const disableAmSelect = !isRoot;
|
||||
|
||||
if (!alertManagerSourceName) {
|
||||
return <Redirect to="/alerting/notifications" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper pageId="receivers">
|
||||
<Field label="Choose alert manager">
|
||||
<Field label={disableAmSelect ? 'Alert manager' : 'Choose alert manager'} disabled={disableAmSelect}>
|
||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
||||
</Field>
|
||||
{error && !loading && (
|
||||
@@ -40,12 +56,27 @@ const Receivers: FC = () => {
|
||||
{error.message || 'Unknown error.'}
|
||||
</Alert>
|
||||
)}
|
||||
{loading && <LoadingPlaceholder text="loading receivers..." />}
|
||||
{result && !loading && !error && (
|
||||
<>
|
||||
<TemplatesTable config={result} />
|
||||
<ReceiversTable config={result} />
|
||||
</>
|
||||
{loading && !config && <LoadingPlaceholder text="loading configuration..." />}
|
||||
{config && !error && (
|
||||
<Switch>
|
||||
<Route exact={true} path="/alerting/notifications">
|
||||
<ReceiversAndTemplatesView config={config} alertManagerName={alertManagerSourceName} />
|
||||
</Route>
|
||||
<Route exact={true} path="/alerting/notifications/templates/new">
|
||||
<NewTemplateView config={config} alertManagerSourceName={alertManagerSourceName} />
|
||||
</Route>
|
||||
<Route exact={true} path="/alerting/notifications/templates/:name/edit">
|
||||
{({ match }: RouteChildrenProps<{ name: string }>) =>
|
||||
match?.params.name && (
|
||||
<EditTemplateView
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
config={config}
|
||||
templateName={decodeURIComponent(match?.params.name)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,8 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
import { SerializedError } from '@reduxjs/toolkit';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { setDataSourceSrv } from '@grafana/runtime';
|
||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { Router } from 'react-router-dom';
|
||||
|
||||
jest.mock('./api/prometheus');
|
||||
jest.mock('./utils/config');
|
||||
@@ -38,7 +39,9 @@ const renderRuleList = () => {
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<RuleList />
|
||||
<Router history={locationService.getHistory()}>
|
||||
<RuleList />
|
||||
</Router>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import RulesFilter from './components/rules/RulesFilter';
|
||||
import { RuleListGroupView } from './components/rules/RuleListGroupView';
|
||||
import { RuleListStateView } from './components/rules/RuleListStateView';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
const VIEWS = {
|
||||
groups: RuleListGroupView,
|
||||
@@ -97,15 +96,13 @@ export const RuleList: FC = () => {
|
||||
)}
|
||||
{promReqeustErrors.map(({ dataSource, error }) => (
|
||||
<div key={dataSource.name}>
|
||||
Failed to load rules state from{' '}
|
||||
<a href={`${config.appSubUrl ?? ''}/datasources/edit/${dataSource.id}`}>{dataSource.name}</a>:{' '}
|
||||
Failed to load rules state from <a href={`datasources/edit/${dataSource.id}`}>{dataSource.name}</a>:{' '}
|
||||
{error.message || 'Unknown error.'}
|
||||
</div>
|
||||
))}
|
||||
{rulerRequestErrors.map(({ dataSource, error }) => (
|
||||
<div key={dataSource.name}>
|
||||
Failed to load rules config from{' '}
|
||||
<a href={`${config.appSubUrl ?? ''}/datasources/edit/${dataSource.id}`}>{dataSource.name}</a>:{' '}
|
||||
Failed to load rules config from <a href={'datasources/edit/${dataSource.id}'}>{dataSource.name}</a>:{' '}
|
||||
{error.message || 'Unknown error.'}
|
||||
</div>
|
||||
))}
|
||||
@@ -117,19 +114,19 @@ export const RuleList: FC = () => {
|
||||
<div className={styles.break} />
|
||||
<div className={styles.buttonsContainer}>
|
||||
<ButtonGroup>
|
||||
<a href={urlUtil.renderUrl(`${config.appSubUrl ?? ''}/alerting/list`, { ...queryParams, view: 'group' })}>
|
||||
<a href={urlUtil.renderUrl('alerting/list', { ...queryParams, view: 'group' })}>
|
||||
<ToolbarButton variant={view === 'groups' ? 'active' : 'default'} icon="folder">
|
||||
Groups
|
||||
</ToolbarButton>
|
||||
</a>
|
||||
<a href={urlUtil.renderUrl(`${config.appSubUrl ?? ''}/alerting/list`, { ...queryParams, view: 'state' })}>
|
||||
<a href={urlUtil.renderUrl('alerting/list', { ...queryParams, view: 'state' })}>
|
||||
<ToolbarButton variant={view === 'state' ? 'active' : 'default'} icon="heart-rate">
|
||||
State
|
||||
</ToolbarButton>
|
||||
</a>
|
||||
</ButtonGroup>
|
||||
<div />
|
||||
<a href={`${config.appSubUrl ?? ''}/alerting/new`}>
|
||||
<a href={'alerting/new'}>
|
||||
<Button icon="plus">New alert rule</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Field, Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||
@@ -15,10 +16,17 @@ const Silences: FC = () => {
|
||||
const silences = useUnifiedAlertingSelector((state) => state.silences);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchSilencesAction(alertManagerSourceName));
|
||||
if (alertManagerSourceName) {
|
||||
dispatch(fetchSilencesAction(alertManagerSourceName));
|
||||
}
|
||||
}, [alertManagerSourceName, dispatch]);
|
||||
|
||||
const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState;
|
||||
const { result, loading, error } =
|
||||
(alertManagerSourceName && silences[alertManagerSourceName]) || initialAsyncRequestState;
|
||||
|
||||
if (!alertManagerSourceName) {
|
||||
return <Redirect to="/alerting/silences" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper pageId="silences">
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
|
||||
// "grafana" for grafana-managed, otherwise a datasource name
|
||||
export async function fetchAlertManagerConfig(alertmanagerSourceName: string): Promise<AlertManagerCortexConfig> {
|
||||
export async function fetchAlertManagerConfig(alertManagerSourceName: string): Promise<AlertManagerCortexConfig> {
|
||||
try {
|
||||
const result = await getBackendSrv()
|
||||
.fetch<AlertManagerCortexConfig>({
|
||||
url: `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/config/api/v1/alerts`,
|
||||
url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/config/api/v1/alerts`,
|
||||
showErrorAlert: false,
|
||||
showSuccessAlert: false,
|
||||
})
|
||||
@@ -26,7 +26,7 @@ export async function fetchAlertManagerConfig(alertmanagerSourceName: string): P
|
||||
} catch (e) {
|
||||
// if no config has been uploaded to grafana, it returns error instead of latest config
|
||||
if (
|
||||
alertmanagerSourceName === GRAFANA_RULES_SOURCE_NAME &&
|
||||
alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME &&
|
||||
e.data?.message?.includes('failed to get latest configuration')
|
||||
) {
|
||||
return {
|
||||
@@ -39,19 +39,24 @@ export async function fetchAlertManagerConfig(alertmanagerSourceName: string): P
|
||||
}
|
||||
|
||||
export async function updateAlertmanagerConfig(
|
||||
alertmanagerSourceName: string,
|
||||
alertManagerSourceName: string,
|
||||
config: AlertManagerCortexConfig
|
||||
): Promise<void> {
|
||||
await getBackendSrv().post(
|
||||
`/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/config/api/v1/alerts`,
|
||||
config
|
||||
);
|
||||
await getBackendSrv()
|
||||
.fetch({
|
||||
method: 'POST',
|
||||
url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/config/api/v1/alerts`,
|
||||
data: config,
|
||||
showErrorAlert: false,
|
||||
showSuccessAlert: false,
|
||||
})
|
||||
.toPromise();
|
||||
}
|
||||
|
||||
export async function fetchSilences(alertmanagerSourceName: string): Promise<Silence[]> {
|
||||
export async function fetchSilences(alertManagerSourceName: string): Promise<Silence[]> {
|
||||
const result = await getBackendSrv()
|
||||
.fetch<Silence[]>({
|
||||
url: `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences`,
|
||||
url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/api/v2/silences`,
|
||||
showErrorAlert: false,
|
||||
showSuccessAlert: false,
|
||||
})
|
||||
|
||||
@@ -7,9 +7,10 @@ import { getAllDataSources } from '../utils/config';
|
||||
interface Props {
|
||||
onChange: (alertManagerSourceName: string) => void;
|
||||
current?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AlertManagerPicker: FC<Props> = ({ onChange, current }) => {
|
||||
export const AlertManagerPicker: FC<Props> = ({ onChange, current, disabled = false }) => {
|
||||
const options: Array<SelectableValue<string>> = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -31,6 +32,7 @@ export const AlertManagerPicker: FC<Props> = ({ onChange, current }) => {
|
||||
|
||||
return (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
width={29}
|
||||
className="ds-picker select-container"
|
||||
isMulti={false}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { InfoBox } from '@grafana/ui';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import React, { FC } from 'react';
|
||||
import { TemplateForm } from './TemplateForm';
|
||||
|
||||
interface Props {
|
||||
templateName: string;
|
||||
config: AlertManagerCortexConfig;
|
||||
alertManagerSourceName: string;
|
||||
}
|
||||
|
||||
export const EditTemplateView: FC<Props> = ({ config, templateName, alertManagerSourceName }) => {
|
||||
const template = config.template_files?.[templateName];
|
||||
if (!template) {
|
||||
return (
|
||||
<InfoBox severity="error" title="Template not found">
|
||||
Sorry, this template does not seem to exit.
|
||||
</InfoBox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TemplateForm
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
config={config}
|
||||
existing={{ name: templateName, content: template }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import React, { FC } from 'react';
|
||||
import { TemplateForm } from './TemplateForm';
|
||||
|
||||
interface Props {
|
||||
config: AlertManagerCortexConfig;
|
||||
alertManagerSourceName: string;
|
||||
}
|
||||
|
||||
export const NewTemplateView: FC<Props> = ({ config, alertManagerSourceName }) => {
|
||||
return <TemplateForm config={config} alertManagerSourceName={alertManagerSourceName} />;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import React, { FC } from 'react';
|
||||
import { ReceiversTable } from './ReceiversTable';
|
||||
import { TemplatesTable } from './TemplatesTable';
|
||||
|
||||
interface Props {
|
||||
config: AlertManagerCortexConfig;
|
||||
alertManagerName: string;
|
||||
}
|
||||
|
||||
export const ReceiversAndTemplatesView: FC<Props> = ({ config, alertManagerName }) => (
|
||||
<>
|
||||
<TemplatesTable config={config} alertManagerName={alertManagerName} />
|
||||
<ReceiversTable config={config} alertManagerName={alertManagerName} />
|
||||
</>
|
||||
);
|
||||
@@ -1,15 +1,16 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { Button, useStyles } from '@grafana/ui';
|
||||
import { LinkButton, useStyles } from '@grafana/ui';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
addButtonLabel: string;
|
||||
addButtonTo: string;
|
||||
}
|
||||
|
||||
export const ReceiversSection: FC<Props> = ({ title, description, addButtonLabel, children }) => {
|
||||
export const ReceiversSection: FC<Props> = ({ title, description, addButtonLabel, addButtonTo, children }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
return (
|
||||
<>
|
||||
@@ -18,7 +19,9 @@ export const ReceiversSection: FC<Props> = ({ title, description, addButtonLabel
|
||||
<h4>{title}</h4>
|
||||
<p className={styles.description}>{description}</p>
|
||||
</div>
|
||||
<Button icon="plus">{addButtonLabel}</Button>
|
||||
<LinkButton href={addButtonTo} icon="plus">
|
||||
{addButtonLabel}
|
||||
</LinkButton>
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,7 @@ const renderReceieversTable = async (receivers: Receiver[], notifiers: NotifierD
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<ReceiversTable config={config} />
|
||||
<ReceiversTable config={config} alertManagerName="alertmanager-1" />
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,15 +4,16 @@ import React, { FC, useMemo } from 'react';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { getAlertTableStyles } from '../../styles/table';
|
||||
import { extractReadableNotifierTypes } from '../../utils/receivers';
|
||||
import { ActionButton } from '../rules/ActionButton';
|
||||
import { ActionIcon } from '../rules/ActionIcon';
|
||||
import { ReceiversSection } from './ReceiversSection';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
|
||||
interface Props {
|
||||
config: AlertManagerCortexConfig;
|
||||
alertManagerName: string;
|
||||
}
|
||||
|
||||
export const ReceiversTable: FC<Props> = ({ config }) => {
|
||||
export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
|
||||
const tableStyles = useStyles(getAlertTableStyles);
|
||||
|
||||
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
|
||||
@@ -31,6 +32,7 @@ export const ReceiversTable: FC<Props> = ({ config }) => {
|
||||
title="Contact points"
|
||||
description="Define where the notifications will be sent to, for example email or Slack."
|
||||
addButtonLabel="New contact point"
|
||||
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
|
||||
>
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
@@ -56,7 +58,14 @@ export const ReceiversTable: FC<Props> = ({ config }) => {
|
||||
<td>{receiver.name}</td>
|
||||
<td>{receiver.types.join(', ')}</td>
|
||||
<td className={tableStyles.actionsCell}>
|
||||
<ActionButton icon="pen">Edit</ActionButton>
|
||||
<ActionIcon
|
||||
href={makeAMLink(
|
||||
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
|
||||
alertManagerName
|
||||
)}
|
||||
tooltip="edit receiver"
|
||||
icon="pen"
|
||||
/>
|
||||
<ActionIcon tooltip="delete receiver" icon="trash-alt" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaThemeV2 } from '@grafana/data';
|
||||
import { Alert, Button, Field, Input, LinkButton, TextArea, useStyles2 } from '@grafana/ui';
|
||||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import React, { FC } from 'react';
|
||||
import { useForm, Validate } from 'react-hook-form';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { updateAlertManagerConfigAction } from '../../state/actions';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { ensureDefine } from '../../utils/templates';
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const defaults: Values = Object.freeze({
|
||||
name: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
interface Props {
|
||||
existing?: Values;
|
||||
config: AlertManagerCortexConfig;
|
||||
alertManagerSourceName: string;
|
||||
}
|
||||
|
||||
export const TemplateForm: FC<Props> = ({ existing, alertManagerSourceName, config }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
|
||||
|
||||
const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
||||
|
||||
const submit = (values: Values) => {
|
||||
// wrap content in "define" if it's not already wrapped, in case user did not do it/
|
||||
// it's not obvious that this is needed for template to work
|
||||
const content = ensureDefine(values.name, values.content);
|
||||
|
||||
// add new template to template map
|
||||
const template_files = {
|
||||
...config.template_files,
|
||||
[values.name]: content,
|
||||
};
|
||||
|
||||
// delete existing one (if name changed, otherwise it was overwritten in previous step)
|
||||
if (existing && existing.name !== values.name) {
|
||||
delete template_files[existing.name];
|
||||
}
|
||||
|
||||
// make sure name for the template is configured on the alertmanager config object
|
||||
const templates = [
|
||||
...(config.alertmanager_config.templates ?? []).filter((name) => name !== existing?.name),
|
||||
values.name,
|
||||
];
|
||||
|
||||
const newConfig: AlertManagerCortexConfig = {
|
||||
template_files,
|
||||
alertmanager_config: {
|
||||
...config.alertmanager_config,
|
||||
templates,
|
||||
},
|
||||
};
|
||||
dispatch(updateAlertManagerConfigAction({ alertManagerSourceName, newConfig, oldConfig: config }));
|
||||
};
|
||||
|
||||
const { handleSubmit, register, errors } = useForm<Values>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: existing ?? defaults,
|
||||
});
|
||||
|
||||
const validateNameIsUnique: Validate = (name: string) => {
|
||||
return !config.template_files[name] || existing?.name === name
|
||||
? true
|
||||
: 'Another template with this name already exists.';
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submit)}>
|
||||
<h4>{existing ? 'Edit message template' : 'Create message template'}</h4>
|
||||
{error && (
|
||||
<Alert severity="error" title="Error saving template">
|
||||
{error.message || (error as any)?.data?.message || String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
<Field label="Template name" error={errors?.name?.message} invalid={!!errors.name?.message}>
|
||||
<Input
|
||||
width={42}
|
||||
autoFocus={true}
|
||||
ref={register({
|
||||
required: { value: true, message: 'Required.' },
|
||||
validate: { nameIsUnique: validateNameIsUnique },
|
||||
})}
|
||||
name="name"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
description={
|
||||
<>
|
||||
You can use the{' '}
|
||||
<a
|
||||
href="https://pkg.go.dev/text/template?utm_source=godoc"
|
||||
target="__blank"
|
||||
rel="noreferrer"
|
||||
className={styles.externalLink}
|
||||
>
|
||||
Go templating language
|
||||
</a>
|
||||
.{' '}
|
||||
<a
|
||||
href="https://prometheus.io/blog/2016/03/03/custom-alertmanager-templates/"
|
||||
target="__blank"
|
||||
rel="noreferrer"
|
||||
className={styles.externalLink}
|
||||
>
|
||||
More info about alertmanager templates
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
label="Content"
|
||||
error={errors?.content?.message}
|
||||
invalid={!!errors.content?.message}
|
||||
>
|
||||
<TextArea
|
||||
className={styles.textarea}
|
||||
ref={register({ required: { value: true, message: 'Required.' } })}
|
||||
name="content"
|
||||
rows={12}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.buttons}>
|
||||
{loading && (
|
||||
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
||||
Saving...
|
||||
</Button>
|
||||
)}
|
||||
{!loading && <Button variant="primary">Save template</Button>}
|
||||
<LinkButton
|
||||
disabled={loading}
|
||||
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaThemeV2) => ({
|
||||
externalLink: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
text-decoration: underline;
|
||||
`,
|
||||
buttons: css`
|
||||
& > * + * {
|
||||
margin-left: ${theme.spacing(1)};
|
||||
}
|
||||
`,
|
||||
textarea: css`
|
||||
max-width: 758px;
|
||||
`,
|
||||
});
|
||||
@@ -4,15 +4,16 @@ import React, { FC, Fragment, useMemo, useState } from 'react';
|
||||
import { getAlertTableStyles } from '../../styles/table';
|
||||
import { CollapseToggle } from '../CollapseToggle';
|
||||
import { DetailsField } from '../DetailsField';
|
||||
import { ActionButton } from '../rules/ActionButton';
|
||||
import { ActionIcon } from '../rules/ActionIcon';
|
||||
import { ReceiversSection } from './ReceiversSection';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
|
||||
interface Props {
|
||||
config: AlertManagerCortexConfig;
|
||||
alertManagerName: string;
|
||||
}
|
||||
|
||||
export const TemplatesTable: FC<Props> = ({ config }) => {
|
||||
export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
|
||||
const [expandedTemplates, setExpandedTemplates] = useState<Record<string, boolean>>({});
|
||||
const tableStyles = useStyles(getAlertTableStyles);
|
||||
|
||||
@@ -22,7 +23,8 @@ export const TemplatesTable: FC<Props> = ({ config }) => {
|
||||
<ReceiversSection
|
||||
title="Message templates"
|
||||
description="Templates construct the messages that get sent to the contact points."
|
||||
addButtonLabel="New templates"
|
||||
addButtonLabel="New template"
|
||||
addButtonTo={makeAMLink('/alerting/notifications/templates/new', alertManagerName)}
|
||||
>
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
@@ -56,7 +58,14 @@ export const TemplatesTable: FC<Props> = ({ config }) => {
|
||||
</td>
|
||||
<td>{name}</td>
|
||||
<td className={tableStyles.actionsCell}>
|
||||
<ActionButton icon="pen">Edit</ActionButton>
|
||||
<ActionIcon
|
||||
href={makeAMLink(
|
||||
`/alerting/notifications/templates/${encodeURIComponent(name)}/edit`,
|
||||
alertManagerName
|
||||
)}
|
||||
tooltip="edit template"
|
||||
icon="pen"
|
||||
/>
|
||||
<ActionIcon tooltip="delete template" icon="trash-alt" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useDispatch } from 'react-redux';
|
||||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||
import { rulerRuleToFormValues, defaultFormValues } from '../../utils/rule-form';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
type Props = {
|
||||
existing?: RuleWithLocation;
|
||||
@@ -72,7 +71,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
||||
<FormContext {...formAPI}>
|
||||
<form onSubmit={handleSubmit((values) => submit(values, false))} className={styles.form}>
|
||||
<PageToolbar title="Create alert rule" pageIcon="bell" className={styles.toolbar}>
|
||||
<Link to={`${config.appSubUrl ?? ''}/alerting/list`}>
|
||||
<Link to="/alerting/list">
|
||||
<ToolbarButton variant="default" disabled={submitState.loading} type="button">
|
||||
Cancel
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import React, { FC } from 'react';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
export const NoRulesSplash: FC = () => (
|
||||
<EmptyListCTA
|
||||
title="You haven`t created any alert rules yet"
|
||||
buttonIcon="bell"
|
||||
buttonLink={`${config.appSubUrl ?? ''}/alerting/new`}
|
||||
buttonLink={'alerting/new'}
|
||||
buttonTitle="New alert rule"
|
||||
proTip="you can also create alert rules from existing panels and queries."
|
||||
proTipLink="https://grafana.com/docs/"
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ActionIcon } from './ActionIcon';
|
||||
import pluralize from 'pluralize';
|
||||
import { useHasRuler } from '../../hooks/useHasRuler';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
interface Props {
|
||||
namespace: CombinedRuleNamespace;
|
||||
@@ -69,7 +68,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
|
||||
const rulerRule = group.rules[0]?.rulerRule;
|
||||
const folderUID = rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid;
|
||||
if (folderUID) {
|
||||
const baseUrl = `${config.appSubUrl ?? ''}/dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`;
|
||||
const baseUrl = `dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`;
|
||||
actionIcons.push(
|
||||
<ActionIcon key="edit" icon="pen" tooltip="edit" href={baseUrl + '/settings'} target="__blank" />
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useDispatch } from 'react-redux';
|
||||
import { deleteRuleAction } from '../../state/actions';
|
||||
import { useHasRuler } from '../../hooks/useHasRuler';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
interface Props {
|
||||
rules: CombinedRule[];
|
||||
@@ -146,7 +145,7 @@ export const RulesTable: FC<Props> = ({
|
||||
<ActionIcon
|
||||
icon="pen"
|
||||
tooltip="edit rule"
|
||||
href={`${config.appSubUrl ?? ''}/alerting/${encodeURIComponent(
|
||||
href={`alerting/${encodeURIComponent(
|
||||
stringifyRuleIdentifier(
|
||||
getRuleIdentifier(getRulesSourceName(rulesSource), namespace.name, group.name, rulerRule)
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import store from 'app/core/store';
|
||||
import { useCallback } from 'react';
|
||||
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants';
|
||||
import { getAlertManagerDataSources, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
|
||||
const alertmanagerQueryKey = 'alertmanager';
|
||||
const alertmanagerLocalStorageKey = 'alerting-alertmanager';
|
||||
|
||||
function isAlertManagerSource(alertManagerSourceName: string): boolean {
|
||||
return (
|
||||
alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME ||
|
||||
@@ -14,27 +12,39 @@ function isAlertManagerSource(alertManagerSourceName: string): boolean {
|
||||
}
|
||||
|
||||
/* this will return am name either from query params or from local storage or a default (grafana).
|
||||
* it might makes sense to abstract to more generic impl..
|
||||
*
|
||||
* fallbackUrl - if provided, will redirect to this url if alertmanager provided in query no longer
|
||||
*/
|
||||
export function useAlertManagerSourceName(): [string, (alertManagerSourceName: string) => void] {
|
||||
export function useAlertManagerSourceName(): [string | undefined, (alertManagerSourceName: string) => void] {
|
||||
const [queryParams, updateQueryParams] = useQueryParams();
|
||||
|
||||
const update = useCallback(
|
||||
(alertManagerSourceName: string) => {
|
||||
if (isAlertManagerSource(alertManagerSourceName)) {
|
||||
store.set(alertmanagerLocalStorageKey, alertManagerSourceName);
|
||||
updateQueryParams({ [alertmanagerQueryKey]: alertManagerSourceName });
|
||||
if (!isAlertManagerSource(alertManagerSourceName)) {
|
||||
return;
|
||||
}
|
||||
if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) {
|
||||
store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY);
|
||||
updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: null });
|
||||
} else {
|
||||
store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, alertManagerSourceName);
|
||||
updateQueryParams({ [ALERTMANAGER_NAME_QUERY_KEY]: alertManagerSourceName });
|
||||
}
|
||||
},
|
||||
[updateQueryParams]
|
||||
);
|
||||
|
||||
const querySource = queryParams[alertmanagerQueryKey];
|
||||
const querySource = queryParams[ALERTMANAGER_NAME_QUERY_KEY];
|
||||
|
||||
if (querySource && typeof querySource === 'string' && isAlertManagerSource(querySource)) {
|
||||
return [querySource, update];
|
||||
if (querySource && typeof querySource === 'string') {
|
||||
if (isAlertManagerSource(querySource)) {
|
||||
return [querySource, update];
|
||||
} else {
|
||||
// non existing alert manager
|
||||
return [undefined, update];
|
||||
}
|
||||
}
|
||||
const storeSource = store.get(alertmanagerLocalStorageKey);
|
||||
const storeSource = store.get(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY);
|
||||
if (storeSource && typeof storeSource === 'string' && isAlertManagerSource(storeSource)) {
|
||||
update(storeSource);
|
||||
return [storeSource, update];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { locationService, config } from '@grafana/runtime';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types';
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
RulerRuleGroupDTO,
|
||||
RulerRulesConfigDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
import { fetchAlertManagerConfig, fetchSilences } from '../api/alertmanager';
|
||||
import { fetchNotifiers } from '../api/grafana';
|
||||
import { fetchAlertManagerConfig, fetchSilences, updateAlertmanagerConfig } from '../api/alertmanager';
|
||||
import { fetchRules } from '../api/prometheus';
|
||||
import {
|
||||
deleteRulerRulesGroup,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../api/ruler';
|
||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
|
||||
import { makeAMLink } from '../utils/misc';
|
||||
import { withSerializedError } from '../utils/redux';
|
||||
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
||||
import {
|
||||
@@ -297,12 +298,10 @@ export const saveRuleFormAction = createAsyncThunk(
|
||||
throw new Error('Unexpected rule form type');
|
||||
}
|
||||
if (exitOnSave) {
|
||||
locationService.push(`${config.appSubUrl ?? ''}/alerting/list`);
|
||||
locationService.push('/alerting/list');
|
||||
} else {
|
||||
// redirect to edit page
|
||||
const newLocation = `${config.appSubUrl ?? ''}/alerting/${encodeURIComponent(
|
||||
stringifyRuleIdentifier(identifier)
|
||||
)}/edit`;
|
||||
const newLocation = `/alerting/${encodeURIComponent(stringifyRuleIdentifier(identifier))}/edit`;
|
||||
if (locationService.getLocation().pathname !== newLocation) {
|
||||
locationService.replace(newLocation);
|
||||
}
|
||||
@@ -318,3 +317,27 @@ export const fetchGrafanaNotifiersAction = createAsyncThunk(
|
||||
'unifiedalerting/fetchGrafanaNotifiers',
|
||||
(): Promise<NotifierDTO[]> => withSerializedError(fetchNotifiers())
|
||||
);
|
||||
|
||||
interface UpdateALertManagerConfigActionOptions {
|
||||
alertManagerSourceName: string;
|
||||
oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile
|
||||
newConfig: AlertManagerCortexConfig;
|
||||
}
|
||||
|
||||
export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateALertManagerConfigActionOptions, {}>(
|
||||
'unifiedalerting/updateAMConfig',
|
||||
({ alertManagerSourceName, oldConfig, newConfig }, thunkApi): Promise<void> =>
|
||||
withSerializedError(
|
||||
(async () => {
|
||||
const latestConfig = await fetchAlertManagerConfig(alertManagerSourceName);
|
||||
if (JSON.stringify(latestConfig) !== JSON.stringify(oldConfig)) {
|
||||
throw new Error(
|
||||
'It seems configuration has been recently updated. Please reload page and try again to make sure that recent changes are not overwritten.'
|
||||
);
|
||||
}
|
||||
await updateAlertmanagerConfig(alertManagerSourceName, newConfig);
|
||||
appEvents.emit(AppEvents.alertSuccess, ['Template saved.']);
|
||||
locationService.push(makeAMLink('/alerting/notifications', alertManagerSourceName));
|
||||
})()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchRulerRulesAction,
|
||||
fetchSilencesAction,
|
||||
saveRuleFormAction,
|
||||
updateAlertManagerConfigAction,
|
||||
} from './actions';
|
||||
|
||||
export const reducer = combineReducers({
|
||||
@@ -25,6 +26,7 @@ export const reducer = combineReducers({
|
||||
existingRule: createAsyncSlice('existingRule', fetchExistingRuleAction).reducer,
|
||||
}),
|
||||
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
|
||||
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
|
||||
});
|
||||
|
||||
export type UnifiedAlertingState = ReturnType<typeof reducer>;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export const RULER_NOT_SUPPORTED_MSG = 'ruler not supported';
|
||||
|
||||
export const RULE_LIST_POLL_INTERVAL_MS = 20000;
|
||||
|
||||
export const ALERTMANAGER_NAME_QUERY_KEY = 'alertmanager';
|
||||
export const ALERTMANAGER_NAME_LOCAL_STORAGE_KEY = 'alerting-alertmanager';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { urlUtil, UrlQueryMap } from '@grafana/data';
|
||||
import { RuleFilterState } from 'app/types/unified-alerting';
|
||||
import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
|
||||
|
||||
export function createExploreLink(dataSourceName: string, query: string) {
|
||||
return urlUtil.renderUrl(config.appSubUrl + '/explore', {
|
||||
return urlUtil.renderUrl('explore', {
|
||||
left: JSON.stringify([
|
||||
'now-1h',
|
||||
'now',
|
||||
@@ -46,3 +46,7 @@ export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): RuleFilterSta
|
||||
export function recordToArray(record: Record<string, string>): Array<{ key: string; value: string }> {
|
||||
return Object.entries(record).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
export function makeAMLink(path: string, alertManagerName?: string): string {
|
||||
return `${path}${alertManagerName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${encodeURIComponent(alertManagerName)}` : ''}`;
|
||||
}
|
||||
|
||||
14
public/app/features/alerting/unified/utils/templates.ts
Normal file
14
public/app/features/alerting/unified/utils/templates.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function ensureDefine(templateName: string, templateContent: string): string {
|
||||
// notification template content must be wrapped in {{ define "name" }} tag,
|
||||
// but this is not obvious because user also has to provide name separately in the form.
|
||||
// so if user does not manually add {{ define }} tag, we do it automatically
|
||||
let content = templateContent.trim();
|
||||
if (!content.match(/\{\{\s*define/)) {
|
||||
const indentedContent = content
|
||||
.split('\n')
|
||||
.map((line) => ' ' + line)
|
||||
.join('\n');
|
||||
content = `{{ define "${templateName}" }}\n${indentedContent}\n{{ end }}`;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
@@ -375,6 +375,18 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/notifications/templates/new',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/notifications/templates/:id/edit',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/notification/new',
|
||||
component: SafeDynamicImport(
|
||||
|
||||
Reference in New Issue
Block a user