mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: separate namespace & group inputs for system alerts (#33026)
This commit is contained in:
@@ -70,7 +70,7 @@ async function rulerGetRequest<T>(url: string, empty: T): Promise<T> {
|
||||
.toPromise();
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
if (e?.status === 404) {
|
||||
if (e?.status === 404 || e?.data?.message?.includes('group does not exist')) {
|
||||
return empty;
|
||||
} else if (e?.status === 500 && e?.data?.message?.includes('mapping values are not allowed in this context')) {
|
||||
throw {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Cascader, CascaderOption } from '@grafana/ui';
|
||||
import React, { FC, useEffect, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
|
||||
import { fetchRulerRulesAction } from '../state/actions';
|
||||
|
||||
interface RuleGroupValue {
|
||||
namespace: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: RuleGroupValue;
|
||||
onChange: (value: RuleGroupValue) => void;
|
||||
dataSourceName: string;
|
||||
}
|
||||
|
||||
const stringifyValue = ({ namespace, group }: RuleGroupValue) => namespace + '|||' + group;
|
||||
const parseValue = (value: string): RuleGroupValue => {
|
||||
const [namespace, group] = value.split('|||');
|
||||
return { namespace, group };
|
||||
};
|
||||
|
||||
export const RuleGroupPicker: FC<Props> = ({ value, onChange, dataSourceName }) => {
|
||||
const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(fetchRulerRulesAction(dataSourceName));
|
||||
}, [dataSourceName, dispatch]);
|
||||
|
||||
const rulesConfig = rulerRequests[dataSourceName]?.result;
|
||||
|
||||
const options = useMemo((): CascaderOption[] => {
|
||||
if (rulesConfig) {
|
||||
return Object.entries(rulesConfig).map(([namespace, group]) => {
|
||||
return {
|
||||
label: namespace,
|
||||
value: namespace,
|
||||
items: group.map(({ name }) => {
|
||||
return { label: name, value: stringifyValue({ namespace, group: name }) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}, [rulesConfig]);
|
||||
|
||||
// @TODO replace cascader with separate dropdowns
|
||||
return (
|
||||
<Cascader
|
||||
placeholder="Select a rule group"
|
||||
onSelect={(value) => {
|
||||
console.log('selected', value);
|
||||
onChange(parseValue(value));
|
||||
}}
|
||||
initialValue={value ? stringifyValue(value) : undefined}
|
||||
displayAllSelectedLevels={true}
|
||||
separator=" > "
|
||||
key={JSON.stringify(options)}
|
||||
options={options}
|
||||
changeOnSelect={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -7,9 +7,9 @@ import { RuleEditorSection } from './RuleEditorSection';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { DataSourcePicker, DataSourcePickerProps } from '@grafana/runtime';
|
||||
import { RuleGroupPicker } from '../RuleGroupPicker';
|
||||
import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
|
||||
import { RuleFolderPicker } from './RuleFolderPicker';
|
||||
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
|
||||
|
||||
const alertTypeOptions: SelectableValue[] = [
|
||||
{
|
||||
@@ -123,27 +123,8 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{ruleFormType === RuleFormType.system && (
|
||||
<Field
|
||||
label="Group"
|
||||
className={styles.formInput}
|
||||
error={errors.location?.message}
|
||||
invalid={!!errors.location?.message}
|
||||
>
|
||||
{dataSourceName ? (
|
||||
<InputControl
|
||||
as={RuleGroupPicker}
|
||||
name="location"
|
||||
control={control}
|
||||
dataSourceName={dataSourceName}
|
||||
rules={{
|
||||
required: { value: true, message: 'Please select a group' },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Select placeholder="Select a data source first" onChange={() => {}} disabled={true} />
|
||||
)}
|
||||
</Field>
|
||||
{ruleFormType === RuleFormType.system && dataSourceName && (
|
||||
<GroupAndNamespaceFields dataSourceName={dataSourceName} />
|
||||
)}
|
||||
{ruleFormType === RuleFormType.threshold && (
|
||||
<Field
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Input, Select } from '@grafana/ui';
|
||||
import React, { FC, useMemo, useState } from 'react';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { SelectWithAdd } from './SelectWIthAdd';
|
||||
|
||||
enum AnnotationOptions {
|
||||
description = 'Description',
|
||||
@@ -18,47 +18,21 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AnnotationKeyInput: FC<Props> = ({ value, onChange, existingKeys, width, className }) => {
|
||||
const isCustomByDefault = !!value && !Object.keys(AnnotationOptions).includes(value); // custom by default if value does not match any of available options
|
||||
const [isCustom, setIsCustom] = useState(isCustomByDefault);
|
||||
|
||||
export const AnnotationKeyInput: FC<Props> = ({ value, existingKeys, ...rest }) => {
|
||||
const annotationOptions = useMemo(
|
||||
(): SelectableValue[] => [
|
||||
...Object.entries(AnnotationOptions)
|
||||
(): SelectableValue[] =>
|
||||
Object.entries(AnnotationOptions)
|
||||
.filter(([optKey]) => !existingKeys.includes(optKey)) // remove keys already taken in other annotations
|
||||
.map(([key, value]) => ({ value: key, label: value })),
|
||||
{ value: '__add__', label: '+ Custom name' },
|
||||
],
|
||||
[existingKeys]
|
||||
);
|
||||
|
||||
if (isCustom) {
|
||||
return (
|
||||
<Input
|
||||
width={width}
|
||||
autoFocus={true}
|
||||
value={value || ''}
|
||||
placeholder="key"
|
||||
className={className}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Select
|
||||
width={width}
|
||||
options={annotationOptions}
|
||||
value={value}
|
||||
className={className}
|
||||
onChange={(val: SelectableValue) => {
|
||||
const value = val?.value;
|
||||
if (value === '__add__') {
|
||||
setIsCustom(true);
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SelectWithAdd
|
||||
value={value}
|
||||
options={annotationOptions}
|
||||
custom={!!value && !Object.keys(AnnotationOptions).includes(value)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { fetchRulerRulesAction } from '../../state/actions';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { SelectWithAdd } from './SelectWIthAdd';
|
||||
import { Field, InputControl } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
interface Props {
|
||||
dataSourceName: string;
|
||||
}
|
||||
|
||||
export const GroupAndNamespaceFields: FC<Props> = ({ dataSourceName }) => {
|
||||
const { control, watch, errors, setValue } = useFormContext<RuleFormValues>();
|
||||
|
||||
const [customGroup, setCustomGroup] = useState(false);
|
||||
|
||||
const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(fetchRulerRulesAction(dataSourceName));
|
||||
}, [dataSourceName, dispatch]);
|
||||
|
||||
const rulesConfig = rulerRequests[dataSourceName]?.result;
|
||||
|
||||
const namespace = watch('namespace');
|
||||
|
||||
const namespaceOptions = useMemo(
|
||||
(): Array<SelectableValue<string>> =>
|
||||
rulesConfig ? Object.keys(rulesConfig).map((namespace) => ({ label: namespace, value: namespace })) : [],
|
||||
[rulesConfig]
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
(): Array<SelectableValue<string>> =>
|
||||
(namespace && rulesConfig?.[namespace]?.map((group) => ({ label: group.name, value: group.name }))) || [],
|
||||
[namespace, rulesConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Namespace" error={errors.namespace?.message} invalid={!!errors.namespace?.message}>
|
||||
<InputControl
|
||||
as={SelectWithAdd}
|
||||
className={inputStyle}
|
||||
name="namespace"
|
||||
options={namespaceOptions}
|
||||
control={control}
|
||||
width={42}
|
||||
rules={{
|
||||
required: { value: true, message: 'Required.' },
|
||||
}}
|
||||
onChange={(values) => {
|
||||
setValue('group', ''); //reset if namespace changes
|
||||
return values[0];
|
||||
}}
|
||||
onCustomChange={(custom: boolean) => {
|
||||
custom && setCustomGroup(true);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Group" error={errors.group?.message} invalid={!!errors.group?.message}>
|
||||
<InputControl
|
||||
as={SelectWithAdd}
|
||||
name="group"
|
||||
className={inputStyle}
|
||||
options={groupOptions}
|
||||
width={42}
|
||||
custom={customGroup}
|
||||
control={control}
|
||||
rules={{
|
||||
required: { value: true, message: 'Required.' },
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const inputStyle = css`
|
||||
width: 330px;
|
||||
`;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Input, Select } from '@grafana/ui';
|
||||
import React, { FC, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
onChange: (value: string) => void;
|
||||
options: Array<SelectableValue<string>>;
|
||||
value?: string;
|
||||
addLabel?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
custom?: boolean;
|
||||
onCustomChange?: (custom: boolean) => void;
|
||||
width?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SelectWithAdd: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
placeholder,
|
||||
width,
|
||||
custom,
|
||||
onCustomChange,
|
||||
disabled = false,
|
||||
addLabel = '+ Add new',
|
||||
}) => {
|
||||
const [isCustom, setIsCustom] = useState(custom);
|
||||
|
||||
useEffect(() => {
|
||||
if (custom) {
|
||||
setIsCustom(custom);
|
||||
}
|
||||
}, [custom]);
|
||||
|
||||
const _options = useMemo((): Array<SelectableValue<string>> => [...options, { value: '__add__', label: addLabel }], [
|
||||
options,
|
||||
addLabel,
|
||||
]);
|
||||
|
||||
if (isCustom) {
|
||||
return (
|
||||
<Input
|
||||
width={width}
|
||||
autoFocus={!custom}
|
||||
value={value || ''}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Select
|
||||
width={width}
|
||||
options={_options}
|
||||
value={value}
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(val: SelectableValue) => {
|
||||
const value = val?.value;
|
||||
if (value === '__add__') {
|
||||
setIsCustom(true);
|
||||
if (onCustomChange) {
|
||||
onCustomChange(true);
|
||||
}
|
||||
onChange('');
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -164,9 +164,9 @@ export function deleteRuleAction(ruleIdentifier: RuleIdentifier): ThunkResult<vo
|
||||
}
|
||||
|
||||
async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> {
|
||||
const { dataSourceName, location } = values;
|
||||
const { dataSourceName, group, namespace } = values;
|
||||
const formRule = formValuesToRulerAlertingRuleDTO(values);
|
||||
if (dataSourceName && location) {
|
||||
if (dataSourceName && group && namespace) {
|
||||
// if we're updating a rule...
|
||||
if (existing) {
|
||||
// refetch it so we always have the latest greatest
|
||||
@@ -175,7 +175,7 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
|
||||
throw new Error('Rule not found.');
|
||||
}
|
||||
// if namespace or group was changed, delete the old rule
|
||||
if (freshExisting.namespace !== location.namespace || freshExisting.group.name !== location.group) {
|
||||
if (freshExisting.namespace !== namespace || freshExisting.group.name !== group) {
|
||||
await deleteRule(freshExisting);
|
||||
} else {
|
||||
// if same namespace or group, update the group replacing the old rule with new
|
||||
@@ -185,14 +185,14 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
|
||||
existingRule === freshExisting.rule ? formRule : existingRule
|
||||
),
|
||||
};
|
||||
await setRulerRuleGroup(dataSourceName, location.namespace, payload);
|
||||
return getRuleIdentifier(dataSourceName, location.namespace, location.group, formRule);
|
||||
await setRulerRuleGroup(dataSourceName, namespace, payload);
|
||||
return getRuleIdentifier(dataSourceName, namespace, group, formRule);
|
||||
}
|
||||
}
|
||||
|
||||
// if creating new rule or existing rule was in a different namespace/group, create new rule in target group
|
||||
|
||||
const targetGroup = await fetchRulerRulesGroup(dataSourceName, location.namespace, location.group);
|
||||
const targetGroup = await fetchRulerRulesGroup(dataSourceName, namespace, group);
|
||||
|
||||
const payload: RulerRuleGroupDTO = targetGroup
|
||||
? {
|
||||
@@ -200,12 +200,12 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
|
||||
rules: [...targetGroup.rules, formRule],
|
||||
}
|
||||
: {
|
||||
name: location.group,
|
||||
name: group,
|
||||
rules: [formRule],
|
||||
};
|
||||
|
||||
await setRulerRuleGroup(dataSourceName, location.namespace, payload);
|
||||
return getRuleIdentifier(dataSourceName, location.namespace, location.group, formRule);
|
||||
await setRulerRuleGroup(dataSourceName, namespace, payload);
|
||||
return getRuleIdentifier(dataSourceName, namespace, group, formRule);
|
||||
} else {
|
||||
throw new Error('Data source and location must be specified');
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface RuleFormValues {
|
||||
evaluateFor: string;
|
||||
|
||||
// system alerts
|
||||
location?: { namespace: string; group: string };
|
||||
namespace: string;
|
||||
group: string;
|
||||
forTime: number;
|
||||
forTimeUnit: string;
|
||||
expression: string;
|
||||
|
||||
@@ -29,6 +29,8 @@ export const defaultFormValues: RuleFormValues = Object.freeze({
|
||||
evaluateFor: '5m',
|
||||
|
||||
// system
|
||||
group: '',
|
||||
namespace: '',
|
||||
expression: '',
|
||||
forTime: 1,
|
||||
forTimeUnit: 'm',
|
||||
@@ -114,10 +116,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
|
||||
name: rule.alert,
|
||||
type: RuleFormType.system,
|
||||
dataSourceName: ruleSourceName,
|
||||
location: {
|
||||
namespace,
|
||||
group: group.name,
|
||||
},
|
||||
namespace,
|
||||
group: group.name,
|
||||
expression: rule.expr,
|
||||
forTime,
|
||||
forTimeUnit,
|
||||
|
||||
Reference in New Issue
Block a user