mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: fix route, silence matchers (#34372)
This commit is contained in:
@@ -180,13 +180,14 @@ describe('AmRoutes', () => {
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
subroutes.forEach((route, index) => {
|
||||
Object.entries({
|
||||
...(route.match ?? {}),
|
||||
...(route.match_re ?? {}),
|
||||
}).forEach(([label, value]) => {
|
||||
Object.entries(route.match ?? {}).forEach(([label, value]) => {
|
||||
expect(rows[index]).toHaveTextContent(`${label}=${value}`);
|
||||
});
|
||||
|
||||
Object.entries(route.match_re ?? {}).forEach(([label, value]) => {
|
||||
expect(rows[index]).toHaveTextContent(`${label}=~${value}`);
|
||||
});
|
||||
|
||||
if (route.group_by) {
|
||||
expect(rows[index]).toHaveTextContent(route.group_by.join(', '));
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const AmRoutes: FC = () => {
|
||||
(alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState;
|
||||
|
||||
const config = result?.alertmanager_config;
|
||||
const routes = useMemo(() => amRouteToFormAmRoute(config?.route), [config?.route]);
|
||||
const [routes, id2ExistingRoute] = useMemo(() => amRouteToFormAmRoute(config?.route), [config?.route]);
|
||||
|
||||
const receivers = stringsToSelectableValues(
|
||||
(config?.receivers ?? []).map((receiver: Receiver) => receiver.name)
|
||||
@@ -59,10 +59,13 @@ const AmRoutes: FC = () => {
|
||||
);
|
||||
|
||||
const handleSave = (data: Partial<FormAmRoute>) => {
|
||||
const newData = formAmRouteToAmRoute({
|
||||
...routes,
|
||||
...data,
|
||||
});
|
||||
const newData = formAmRouteToAmRoute(
|
||||
{
|
||||
...routes,
|
||||
...data,
|
||||
},
|
||||
id2ExistingRoute
|
||||
);
|
||||
|
||||
if (isRootRouteEditMode) {
|
||||
exitRootRouteEditMode();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
AlertmanagerGroup,
|
||||
Silence,
|
||||
SilenceCreatePayload,
|
||||
SilenceMatcher,
|
||||
Matcher,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function expireSilence(alertmanagerSourceName: string, silenceID: s
|
||||
|
||||
export async function fetchAlerts(
|
||||
alertmanagerSourceName: string,
|
||||
matchers?: SilenceMatcher[],
|
||||
matchers?: Matcher[],
|
||||
silenced = true,
|
||||
active = true,
|
||||
inhibited = true
|
||||
|
||||
@@ -6,13 +6,14 @@ import { css } from '@emotion/css';
|
||||
interface Props {
|
||||
labelKey: string;
|
||||
value: string;
|
||||
isRegex?: boolean;
|
||||
operator?: string;
|
||||
onRemoveLabel?: () => void;
|
||||
}
|
||||
|
||||
export const AlertLabel: FC<Props> = ({ labelKey, value, isRegex = false, onRemoveLabel }) => (
|
||||
export const AlertLabel: FC<Props> = ({ labelKey, value, operator = '=', onRemoveLabel }) => (
|
||||
<div className={useStyles(getStyles)}>
|
||||
{labelKey}={isRegex && '~'}
|
||||
{labelKey}
|
||||
{operator}
|
||||
{value}
|
||||
{!!onRemoveLabel && <IconButton name="times" size="xs" onClick={onRemoveLabel} />}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FieldArray,
|
||||
Form,
|
||||
HorizontalGroup,
|
||||
IconButton,
|
||||
Input,
|
||||
InputControl,
|
||||
MultiSelect,
|
||||
@@ -47,9 +48,11 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
<Form defaultValues={routes} onSubmit={onSave}>
|
||||
{({ control, register, errors, setValue }) => (
|
||||
<>
|
||||
{/* @ts-ignore-check: react-hook-form made me do this */}
|
||||
<input type="hidden" {...register('id')} />
|
||||
{/* @ts-ignore-check: react-hook-form made me do this */}
|
||||
<FieldArray name="matchers" control={control}>
|
||||
{({ fields, append }) => (
|
||||
{({ fields, append, remove }) => (
|
||||
<>
|
||||
<div>Matchers</div>
|
||||
<div className={styles.matchersContainer}>
|
||||
@@ -57,18 +60,17 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
const localPath = `matchers[${index}]`;
|
||||
|
||||
return (
|
||||
<HorizontalGroup key={field.id}>
|
||||
<HorizontalGroup key={field.id} align="flex-start">
|
||||
<Field
|
||||
label="Label"
|
||||
invalid={!!errors.matchers?.[index]?.label}
|
||||
error={errors.matchers?.[index]?.label?.message}
|
||||
label="Name"
|
||||
invalid={!!errors.matchers?.[index]?.name}
|
||||
error={errors.matchers?.[index]?.name?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`${localPath}.label`, { required: 'Field is required' })}
|
||||
defaultValue={field.label}
|
||||
{...register(`${localPath}.name`, { required: 'Field is required' })}
|
||||
defaultValue={field.name}
|
||||
/>
|
||||
</Field>
|
||||
<span>=</span>
|
||||
<Field
|
||||
label="Value"
|
||||
invalid={!!errors.matchers?.[index]?.value}
|
||||
@@ -82,6 +84,17 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
<Field className={styles.matcherRegexField} label="Regex">
|
||||
<Checkbox {...register(`${localPath}.isRegex`)} defaultChecked={field.isRegex} />
|
||||
</Field>
|
||||
<Field className={styles.matcherRegexField} label="Equal">
|
||||
<Checkbox {...register(`${localPath}.isEqual`)} defaultChecked={field.isEqual} />
|
||||
</Field>
|
||||
<IconButton
|
||||
className={styles.removeButton}
|
||||
tooltip="Remove matcher"
|
||||
name={'trash-alt'}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove
|
||||
</IconButton>
|
||||
</HorizontalGroup>
|
||||
);
|
||||
})}
|
||||
@@ -286,6 +299,10 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
nestedPolicies: css`
|
||||
margin-top: ${commonSpacing};
|
||||
`,
|
||||
removeButton: css`
|
||||
margin-left: ${theme.spacing(1)};
|
||||
margin-top: ${theme.spacing(2.5)};
|
||||
`,
|
||||
buttonGroup: css`
|
||||
margin: ${theme.spacing(6)} 0 ${commonSpacing};
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
prepareItems,
|
||||
removeCustomExpandedContent,
|
||||
} from '../../utils/dynamicTable';
|
||||
import { AlertLabels } from '../AlertLabels';
|
||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
import { AmRoutesExpandedForm } from './AmRoutesExpandedForm';
|
||||
import { AmRoutesExpandedRead } from './AmRoutesExpandedRead';
|
||||
import { Matchers } from '../silences/Matchers';
|
||||
|
||||
export interface AmRoutesTableProps {
|
||||
isAddMode: boolean;
|
||||
@@ -67,17 +67,7 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({ isAddMode, onCancelAdd,
|
||||
id: 'matchingCriteria',
|
||||
label: 'Matching criteria',
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderCell: (item) => (
|
||||
<AlertLabels
|
||||
labels={item.data.matchers.reduce(
|
||||
(acc, matcher) => ({
|
||||
...acc,
|
||||
[matcher.label]: matcher.value,
|
||||
}),
|
||||
{}
|
||||
)}
|
||||
/>
|
||||
),
|
||||
renderCell: (item) => <Matchers matchers={item.data.matchers} />,
|
||||
size: 10,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ReceiversSection } from './ReceiversSection';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { css } from '@emotion/css';
|
||||
import { isReceiverUsed } from '../../utils/alertmanager-config';
|
||||
import { isReceiverUsed } from '../../utils/alertmanager';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { deleteReceiverAction } from '../../state/actions';
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import React, { useCallback } from 'react';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { useStyles } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { SilenceMatcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AlertLabel } from '../AlertLabel';
|
||||
import { matcherToOperator } from '../../utils/alertmanager';
|
||||
|
||||
type MatchersProps = { matchers: SilenceMatcher[]; onRemoveLabel?(index: number): void };
|
||||
type MatchersProps = { matchers: Matcher[]; onRemoveLabel?(index: number): void };
|
||||
|
||||
export const Matchers = ({ matchers, onRemoveLabel }: MatchersProps) => {
|
||||
const styles = useStyles(getStyles);
|
||||
@@ -21,13 +22,14 @@ export const Matchers = ({ matchers, onRemoveLabel }: MatchersProps) => {
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{matchers.map(({ name, value, isRegex }: SilenceMatcher, index) => {
|
||||
{matchers.map((matcher, index) => {
|
||||
const { name, value } = matcher;
|
||||
return (
|
||||
<AlertLabel
|
||||
key={`${name}-${value}-${index}`}
|
||||
labelKey={name}
|
||||
value={value}
|
||||
isRegex={isRegex}
|
||||
operator={matcherToOperator(matcher)}
|
||||
onRemoveLabel={!!onRemoveLabel ? () => removeLabel(index) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Button, Field, Input, InlineLabel, useStyles, Checkbox, IconButton } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { Button, Field, Input, Checkbox, IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useFormContext, useFieldArray } from 'react-hook-form';
|
||||
import { SilenceFormFields } from '../../types/silence-form';
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MatchersField: FC<Props> = ({ className }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
const styles = useStyles2(getStyles);
|
||||
const formApi = useFormContext<SilenceFormFields>();
|
||||
const {
|
||||
register,
|
||||
@@ -24,6 +25,7 @@ const MatchersField: FC<Props> = ({ className }) => {
|
||||
<div>
|
||||
<div className={styles.matchers}>
|
||||
{matchers.map((matcher, index) => {
|
||||
console.log(matcher);
|
||||
return (
|
||||
<div className={styles.row} key={`${matcher.id}`}>
|
||||
<Field
|
||||
@@ -39,7 +41,6 @@ const MatchersField: FC<Props> = ({ className }) => {
|
||||
placeholder="name"
|
||||
/>
|
||||
</Field>
|
||||
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
||||
<Field
|
||||
label="Value"
|
||||
invalid={!!errors?.matchers?.[index]?.value}
|
||||
@@ -53,9 +54,12 @@ const MatchersField: FC<Props> = ({ className }) => {
|
||||
placeholder="value"
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.regexCheckbox} label="Regex">
|
||||
<Field label="Regex">
|
||||
<Checkbox {...register(`matchers.${index}.isRegex` as const)} defaultChecked={matcher.isRegex} />
|
||||
</Field>
|
||||
<Field label="Equal">
|
||||
<Checkbox {...register(`matchers.${index}.isEqual` as const)} defaultChecked={matcher.isEqual} />
|
||||
</Field>
|
||||
{matchers.length > 1 && (
|
||||
<IconButton
|
||||
className={styles.removeButton}
|
||||
@@ -75,7 +79,8 @@ const MatchersField: FC<Props> = ({ className }) => {
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
append({ name: '', value: '', isRegex: false });
|
||||
const newMatcher: Matcher = { name: '', value: '', isRegex: false, isEqual: true };
|
||||
append(newMatcher);
|
||||
}}
|
||||
>
|
||||
Add matcher
|
||||
@@ -86,34 +91,29 @@ const MatchersField: FC<Props> = ({ className }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
wrapper: css`
|
||||
margin-top: ${theme.spacing.md};
|
||||
margin-top: ${theme.spacing(2)};
|
||||
`,
|
||||
row: css`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: ${theme.colors.bg2};
|
||||
padding: ${theme.spacing.sm} ${theme.spacing.sm} 0 ${theme.spacing.sm};
|
||||
`,
|
||||
equalSign: css`
|
||||
width: 28px;
|
||||
justify-content: center;
|
||||
margin-left: ${theme.spacing.xs};
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
regexCheckbox: css`
|
||||
margin-left: ${theme.spacing.md};
|
||||
background-color: ${theme.colors.background.secondary};
|
||||
padding: ${theme.spacing(1)} ${theme.spacing(1)} 0 ${theme.spacing(1)};
|
||||
& > * + * {
|
||||
margin-left: ${theme.spacing(2)};
|
||||
}
|
||||
`,
|
||||
removeButton: css`
|
||||
margin-left: ${theme.spacing.sm};
|
||||
margin-left: ${theme.spacing(1)};
|
||||
margin-top: ${theme.spacing(2.5)};
|
||||
`,
|
||||
matchers: css`
|
||||
max-width: 585px;
|
||||
margin: ${theme.spacing.sm} 0;
|
||||
padding-top: ${theme.spacing.xs};
|
||||
margin: ${theme.spacing(1)} 0;
|
||||
padding-top: ${theme.spacing(0.5)};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ const getDefaultFormValues = (silence?: Silence): SilenceFormFields => {
|
||||
createdBy: config.bootData.user.name,
|
||||
duration: '2h',
|
||||
isRegex: false,
|
||||
matchers: [{ name: '', value: '', isRegex: false }],
|
||||
matchers: [{ name: '', value: '', isRegex: false, isEqual: true }],
|
||||
matcherName: '',
|
||||
matcherValue: '',
|
||||
timeZone: DefaultTimeZone,
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
ruleWithLocationToRuleIdentifier,
|
||||
stringifyRuleIdentifier,
|
||||
} from '../utils/rules';
|
||||
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager-config';
|
||||
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
export const fetchPromRulesAction = createAsyncThunk(
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
export interface ArrayFieldMatcher {
|
||||
label: string;
|
||||
value: string;
|
||||
isRegex: boolean;
|
||||
}
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
export interface FormAmRoute {
|
||||
matchers: ArrayFieldMatcher[];
|
||||
id: string;
|
||||
matchers: Matcher[];
|
||||
continue: boolean;
|
||||
receiver: string;
|
||||
groupBy: string[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SilenceMatcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { TimeZone } from '@grafana/data';
|
||||
|
||||
export type SilenceFormFields = {
|
||||
@@ -8,7 +8,7 @@ export type SilenceFormFields = {
|
||||
timeZone: TimeZone;
|
||||
duration: string;
|
||||
comment: string;
|
||||
matchers: SilenceMatcher[];
|
||||
matchers: Matcher[];
|
||||
createdBy: string;
|
||||
matcherName: string;
|
||||
matcherValue: string;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { AlertManagerCortexConfig, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
|
||||
// add default receiver if it does not exist
|
||||
if (!config.alertmanager_config.receivers) {
|
||||
config.alertmanager_config.receivers = [{ name: 'default ' }];
|
||||
}
|
||||
// add default route if it does not exists
|
||||
if (!config.alertmanager_config.route) {
|
||||
config.alertmanager_config.route = {
|
||||
receiver: config.alertmanager_config.receivers![0].name,
|
||||
};
|
||||
}
|
||||
if (!config.template_files) {
|
||||
config.template_files = {};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function isReceiverUsedInRoute(receiver: string, route: Route): boolean {
|
||||
return (
|
||||
(route.receiver === receiver || route.routes?.some((route) => isReceiverUsedInRoute(receiver, route))) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function isReceiverUsed(receiver: string, config: AlertManagerCortexConfig): boolean {
|
||||
return (
|
||||
(config.alertmanager_config.route && isReceiverUsedInRoute(receiver, config.alertmanager_config.route)) ?? false
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { parseMatcher, stringifyMatcher } from './alertmanager';
|
||||
|
||||
describe('Alertmanager utils', () => {
|
||||
describe('parseMatcher', () => {
|
||||
it('should parse operators correctly', () => {
|
||||
expect(parseMatcher('foo=bar')).toEqual<Matcher>({
|
||||
name: 'foo',
|
||||
value: 'bar',
|
||||
isRegex: false,
|
||||
isEqual: true,
|
||||
});
|
||||
expect(parseMatcher('foo!=bar')).toEqual<Matcher>({
|
||||
name: 'foo',
|
||||
value: 'bar',
|
||||
isRegex: false,
|
||||
isEqual: false,
|
||||
});
|
||||
expect(parseMatcher('foo =~bar')).toEqual<Matcher>({
|
||||
name: 'foo',
|
||||
value: 'bar',
|
||||
isRegex: true,
|
||||
isEqual: true,
|
||||
});
|
||||
expect(parseMatcher('foo!~ bar')).toEqual<Matcher>({
|
||||
name: 'foo',
|
||||
value: 'bar',
|
||||
isRegex: true,
|
||||
isEqual: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse escaped values correctly', () => {
|
||||
expect(parseMatcher('foo=~"bar\\"baz\\""')).toEqual<Matcher>({
|
||||
name: 'foo',
|
||||
value: 'bar"baz"',
|
||||
isRegex: true,
|
||||
isEqual: true,
|
||||
});
|
||||
expect(parseMatcher('foo=~bar\\"baz\\"')).toEqual<Matcher>({
|
||||
name: 'foo',
|
||||
value: 'bar"baz"',
|
||||
isRegex: true,
|
||||
isEqual: true,
|
||||
});
|
||||
});
|
||||
it('should parse multiple operators values correctly', () => {
|
||||
expect(parseMatcher('foo=~bar=baz!=bad!~br')).toEqual<Matcher>({
|
||||
name: 'foo',
|
||||
value: 'bar=baz!=bad!~br',
|
||||
isRegex: true,
|
||||
isEqual: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringifyMatcher', () => {
|
||||
it('should stringify matcher correctly', () => {
|
||||
expect(
|
||||
stringifyMatcher({
|
||||
name: 'foo',
|
||||
value: 'boo="bar"',
|
||||
isRegex: true,
|
||||
isEqual: false,
|
||||
})
|
||||
).toEqual('foo!~"boo=\\"bar\\""');
|
||||
});
|
||||
});
|
||||
});
|
||||
95
public/app/features/alerting/unified/utils/alertmanager.ts
Normal file
95
public/app/features/alerting/unified/utils/alertmanager.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { AlertManagerCortexConfig, MatcherOperator, Route, Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
|
||||
// add default receiver if it does not exist
|
||||
if (!config.alertmanager_config.receivers) {
|
||||
config.alertmanager_config.receivers = [{ name: 'default ' }];
|
||||
}
|
||||
// add default route if it does not exists
|
||||
if (!config.alertmanager_config.route) {
|
||||
config.alertmanager_config.route = {
|
||||
receiver: config.alertmanager_config.receivers![0].name,
|
||||
};
|
||||
}
|
||||
if (!config.template_files) {
|
||||
config.template_files = {};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function isReceiverUsedInRoute(receiver: string, route: Route): boolean {
|
||||
return (
|
||||
(route.receiver === receiver || route.routes?.some((route) => isReceiverUsedInRoute(receiver, route))) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function isReceiverUsed(receiver: string, config: AlertManagerCortexConfig): boolean {
|
||||
return (
|
||||
(config.alertmanager_config.route && isReceiverUsedInRoute(receiver, config.alertmanager_config.route)) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function matcherToOperator(matcher: Matcher): MatcherOperator {
|
||||
if (matcher.isEqual) {
|
||||
if (matcher.isRegex) {
|
||||
return MatcherOperator.regex;
|
||||
} else {
|
||||
return MatcherOperator.equal;
|
||||
}
|
||||
} else if (matcher.isRegex) {
|
||||
return MatcherOperator.notRegex;
|
||||
} else {
|
||||
return MatcherOperator.notEqual;
|
||||
}
|
||||
}
|
||||
|
||||
const matcherOperators = [
|
||||
MatcherOperator.regex,
|
||||
MatcherOperator.notRegex,
|
||||
MatcherOperator.notEqual,
|
||||
MatcherOperator.equal,
|
||||
];
|
||||
|
||||
function unescapeMatcherValue(value: string) {
|
||||
let trimmed = value.trim().replace(/\\"/g, '"');
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"') && !trimmed.endsWith('\\"')) {
|
||||
trimmed = trimmed.substr(1, trimmed.length - 2);
|
||||
}
|
||||
return trimmed.replace(/\\"/g, '"');
|
||||
}
|
||||
|
||||
function escapeMatcherValue(value: string) {
|
||||
return '"' + value.replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
|
||||
export function stringifyMatcher(matcher: Matcher): string {
|
||||
return `${matcher.name}${matcherToOperator(matcher)}${escapeMatcherValue(matcher.value)}`;
|
||||
}
|
||||
|
||||
export function parseMatcher(matcher: string): Matcher {
|
||||
const trimmed = matcher.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
throw new Error(`PromQL matchers not supported yet, sorry! PromQL matcher found: ${trimmed}`);
|
||||
}
|
||||
const operatorsFound = matcherOperators
|
||||
.map((op): [MatcherOperator, number] => [op, trimmed.indexOf(op)])
|
||||
.filter(([_, idx]) => idx > -1)
|
||||
.sort((a, b) => a[1] - b[1]);
|
||||
|
||||
if (!operatorsFound.length) {
|
||||
throw new Error(`Invalid matcher: ${trimmed}`);
|
||||
}
|
||||
const [operator, idx] = operatorsFound[0];
|
||||
const name = trimmed.substr(0, idx).trim();
|
||||
const value = unescapeMatcherValue(trimmed.substr(idx + operator.length).trim());
|
||||
if (!name) {
|
||||
throw new Error(`Invalid matcher: ${trimmed}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
value,
|
||||
isRegex: operator === MatcherOperator.regex || operator === MatcherOperator.notRegex,
|
||||
isEqual: operator === MatcherOperator.equal || operator === MatcherOperator.regex,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Validate } from 'react-hook-form';
|
||||
import { Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { FormAmRoute, ArrayFieldMatcher } from '../types/amroutes';
|
||||
import { Matcher, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { FormAmRoute } from '../types/amroutes';
|
||||
import { parseInterval, timeOptions } from './time';
|
||||
import { parseMatcher, stringifyMatcher } from './alertmanager';
|
||||
|
||||
const defaultValueAndType: [string, string] = ['', timeOptions[0].value];
|
||||
|
||||
const matchersToArrayFieldMatchers = (
|
||||
matchers: Record<string, string> | undefined,
|
||||
isRegex: boolean
|
||||
): ArrayFieldMatcher[] =>
|
||||
const matchersToArrayFieldMatchers = (matchers: Record<string, string> | undefined, isRegex: boolean): Matcher[] =>
|
||||
Object.entries(matchers ?? {}).reduce(
|
||||
(acc, [label, value]) => [
|
||||
(acc, [name, value]) => [
|
||||
...acc,
|
||||
{
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
isRegex: isRegex,
|
||||
isEqual: true,
|
||||
},
|
||||
],
|
||||
[]
|
||||
@@ -43,13 +42,15 @@ const selectableValueToString = (selectableValue: SelectableValue<string>): stri
|
||||
const selectableValuesToStrings = (arr: Array<SelectableValue<string>> | undefined): string[] =>
|
||||
(arr ?? []).map(selectableValueToString);
|
||||
|
||||
export const emptyArrayFieldMatcher: ArrayFieldMatcher = {
|
||||
label: '',
|
||||
export const emptyArrayFieldMatcher: Matcher = {
|
||||
name: '',
|
||||
value: '',
|
||||
isRegex: false,
|
||||
isEqual: true,
|
||||
};
|
||||
|
||||
export const emptyRoute: FormAmRoute = {
|
||||
id: '',
|
||||
matchers: [emptyArrayFieldMatcher],
|
||||
groupBy: [],
|
||||
routes: [],
|
||||
@@ -63,50 +64,59 @@ export const emptyRoute: FormAmRoute = {
|
||||
repeatIntervalValueType: timeOptions[0].value,
|
||||
};
|
||||
|
||||
export const amRouteToFormAmRoute = (route: Route | undefined): FormAmRoute => {
|
||||
//returns route, and a record mapping id to existing route route
|
||||
export const amRouteToFormAmRoute = (route: Route | undefined): [FormAmRoute, Record<string, Route>] => {
|
||||
if (!route || Object.keys(route).length === 0) {
|
||||
return emptyRoute;
|
||||
return [emptyRoute, {}];
|
||||
}
|
||||
|
||||
const [groupWaitValue, groupWaitValueType] = intervalToValueAndType(route.group_wait);
|
||||
const [groupIntervalValue, groupIntervalValueType] = intervalToValueAndType(route.group_interval);
|
||||
const [repeatIntervalValue, repeatIntervalValueType] = intervalToValueAndType(route.repeat_interval);
|
||||
|
||||
return {
|
||||
matchers: [
|
||||
...matchersToArrayFieldMatchers(route.match, false),
|
||||
...matchersToArrayFieldMatchers(route.match_re, true),
|
||||
],
|
||||
continue: route.continue ?? false,
|
||||
receiver: route.receiver ?? '',
|
||||
groupBy: route.group_by ?? [],
|
||||
groupWaitValue,
|
||||
groupWaitValueType,
|
||||
groupIntervalValue,
|
||||
groupIntervalValueType,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalValueType,
|
||||
routes: (route.routes ?? []).map(amRouteToFormAmRoute),
|
||||
const id = String(Math.random());
|
||||
const id2route = {
|
||||
[id]: route,
|
||||
};
|
||||
const formRoutes: FormAmRoute[] = [];
|
||||
route.routes?.forEach((subRoute) => {
|
||||
const [subFormRoute, subId2Route] = amRouteToFormAmRoute(subRoute);
|
||||
formRoutes.push(subFormRoute);
|
||||
Object.assign(id2route, subId2Route);
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
id,
|
||||
matchers: [
|
||||
...(route.matchers?.map(parseMatcher) ?? []),
|
||||
...matchersToArrayFieldMatchers(route.match, false),
|
||||
...matchersToArrayFieldMatchers(route.match_re, true),
|
||||
],
|
||||
continue: route.continue ?? false,
|
||||
receiver: route.receiver ?? '',
|
||||
groupBy: route.group_by ?? [],
|
||||
groupWaitValue,
|
||||
groupWaitValueType,
|
||||
groupIntervalValue,
|
||||
groupIntervalValueType,
|
||||
repeatIntervalValue,
|
||||
repeatIntervalValueType,
|
||||
routes: formRoutes,
|
||||
},
|
||||
id2route,
|
||||
];
|
||||
};
|
||||
|
||||
export const formAmRouteToAmRoute = (formAmRoute: FormAmRoute): Route => {
|
||||
export const formAmRouteToAmRoute = (formAmRoute: FormAmRoute, id2ExistingRoute: Record<string, Route>): Route => {
|
||||
const existing: Route | undefined = id2ExistingRoute[formAmRoute.id];
|
||||
const amRoute: Route = {
|
||||
...(existing ?? {}),
|
||||
continue: formAmRoute.continue,
|
||||
group_by: formAmRoute.groupBy,
|
||||
...Object.values(formAmRoute.matchers).reduce(
|
||||
(acc, { label, value, isRegex }) => {
|
||||
const target = acc[isRegex ? 'match_re' : 'match'];
|
||||
|
||||
target![label] = value;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
match: {},
|
||||
match_re: {},
|
||||
} as Pick<Route, 'match' | 'match_re'>
|
||||
),
|
||||
matchers: formAmRoute.matchers.length ? formAmRoute.matchers.map(stringifyMatcher) : undefined,
|
||||
match: undefined,
|
||||
match_re: undefined,
|
||||
group_wait: formAmRoute.groupWaitValue
|
||||
? `${formAmRoute.groupWaitValue}${formAmRoute.groupWaitValueType}`
|
||||
: undefined,
|
||||
@@ -116,7 +126,7 @@ export const formAmRouteToAmRoute = (formAmRoute: FormAmRoute): Route => {
|
||||
repeat_interval: formAmRoute.repeatIntervalValue
|
||||
? `${formAmRoute.repeatIntervalValue}${formAmRoute.repeatIntervalValueType}`
|
||||
: undefined,
|
||||
routes: formAmRoute.routes.map(formAmRouteToAmRoute),
|
||||
routes: formAmRoute.routes.map((subRoute) => formAmRouteToAmRoute(subRoute, id2ExistingRoute)),
|
||||
};
|
||||
|
||||
if (formAmRoute.receiver) {
|
||||
|
||||
@@ -98,6 +98,7 @@ export type Route = {
|
||||
receiver?: string;
|
||||
group_by?: string[];
|
||||
continue?: boolean;
|
||||
matchers?: string[];
|
||||
match?: Record<string, string>;
|
||||
match_re?: Record<string, string>;
|
||||
group_wait?: string;
|
||||
@@ -142,10 +143,11 @@ export type AlertmanagerConfig = {
|
||||
receivers?: Receiver[];
|
||||
};
|
||||
|
||||
export type SilenceMatcher = {
|
||||
export type Matcher = {
|
||||
name: string;
|
||||
value: string;
|
||||
isRegex: boolean;
|
||||
isEqual: boolean;
|
||||
};
|
||||
|
||||
export enum SilenceState {
|
||||
@@ -160,9 +162,16 @@ export enum AlertState {
|
||||
Suppressed = 'suppressed',
|
||||
}
|
||||
|
||||
export enum MatcherOperator {
|
||||
equal = '=',
|
||||
notEqual = '!=',
|
||||
regex = '=~',
|
||||
notRegex = '!~',
|
||||
}
|
||||
|
||||
export type Silence = {
|
||||
id: string;
|
||||
matchers?: SilenceMatcher[];
|
||||
matchers?: Matcher[];
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
updatedAt: string;
|
||||
@@ -175,7 +184,7 @@ export type Silence = {
|
||||
|
||||
export type SilenceCreatePayload = {
|
||||
id?: string;
|
||||
matchers?: SilenceMatcher[];
|
||||
matchers?: Matcher[];
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
createdBy: string;
|
||||
|
||||
Reference in New Issue
Block a user