Alerting: separate namespace & group inputs for system alerts (#33026)

pull/33108/head
Domas 4 years ago committed by GitHub
parent dbe2f1871f
commit 0491fe0a5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      public/app/features/alerting/unified/api/ruler.ts
  2. 64
      public/app/features/alerting/unified/components/RuleGroupPicker.tsx
  3. 25
      public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
  4. 44
      public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx
  5. 85
      public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx
  6. 79
      public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx
  7. 18
      public/app/features/alerting/unified/state/actions.ts
  8. 3
      public/app/features/alerting/unified/types/rule-form.ts
  9. 4
      public/app/features/alerting/unified/utils/rule-form.ts

@ -70,7 +70,7 @@ async function rulerGetRequest<T>(url: string, empty: T): Promise<T> {
.toPromise(); .toPromise();
return response.data; return response.data;
} catch (e) { } catch (e) {
if (e?.status === 404) { if (e?.status === 404 || e?.data?.message?.includes('group does not exist')) {
return empty; return empty;
} else if (e?.status === 500 && e?.data?.message?.includes('mapping values are not allowed in this context')) { } else if (e?.status === 500 && e?.data?.message?.includes('mapping values are not allowed in this context')) {
throw { 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 { useFormContext } from 'react-hook-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form'; import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { DataSourcePicker, DataSourcePickerProps } from '@grafana/runtime'; import { DataSourcePicker, DataSourcePickerProps } from '@grafana/runtime';
import { RuleGroupPicker } from '../RuleGroupPicker';
import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler'; import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
import { RuleFolderPicker } from './RuleFolderPicker'; import { RuleFolderPicker } from './RuleFolderPicker';
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
const alertTypeOptions: SelectableValue[] = [ const alertTypeOptions: SelectableValue[] = [
{ {
@ -123,27 +123,8 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
</Field> </Field>
)} )}
</div> </div>
{ruleFormType === RuleFormType.system && ( {ruleFormType === RuleFormType.system && dataSourceName && (
<Field <GroupAndNamespaceFields dataSourceName={dataSourceName} />
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.threshold && ( {ruleFormType === RuleFormType.threshold && (
<Field <Field

@ -1,6 +1,6 @@
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { Input, Select } from '@grafana/ui'; import React, { FC, useMemo } from 'react';
import React, { FC, useMemo, useState } from 'react'; import { SelectWithAdd } from './SelectWIthAdd';
enum AnnotationOptions { enum AnnotationOptions {
description = 'Description', description = 'Description',
@ -18,47 +18,21 @@ interface Props {
className?: string; className?: string;
} }
export const AnnotationKeyInput: FC<Props> = ({ value, onChange, existingKeys, width, className }) => { export const AnnotationKeyInput: FC<Props> = ({ value, existingKeys, ...rest }) => {
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);
const annotationOptions = useMemo( const annotationOptions = useMemo(
(): SelectableValue[] => [ (): SelectableValue[] =>
...Object.entries(AnnotationOptions) Object.entries(AnnotationOptions)
.filter(([optKey]) => !existingKeys.includes(optKey)) // remove keys already taken in other annotations .filter(([optKey]) => !existingKeys.includes(optKey)) // remove keys already taken in other annotations
.map(([key, value]) => ({ value: key, label: value })), .map(([key, value]) => ({ value: key, label: value })),
{ value: '__add__', label: '+ Custom name' },
],
[existingKeys] [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 ( return (
<Select <SelectWithAdd
width={width}
options={annotationOptions}
value={value} value={value}
className={className} options={annotationOptions}
onChange={(val: SelectableValue) => { custom={!!value && !Object.keys(AnnotationOptions).includes(value)}
const value = val?.value; {...rest}
if (value === '__add__') {
setIsCustom(true);
} else {
onChange(value);
}
}}
/> />
); );
}
}; };

@ -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> { async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> {
const { dataSourceName, location } = values; const { dataSourceName, group, namespace } = values;
const formRule = formValuesToRulerAlertingRuleDTO(values); const formRule = formValuesToRulerAlertingRuleDTO(values);
if (dataSourceName && location) { if (dataSourceName && group && namespace) {
// if we're updating a rule... // if we're updating a rule...
if (existing) { if (existing) {
// refetch it so we always have the latest greatest // 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.'); throw new Error('Rule not found.');
} }
// if namespace or group was changed, delete the old rule // 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); await deleteRule(freshExisting);
} else { } else {
// if same namespace or group, update the group replacing the old rule with new // 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 existingRule === freshExisting.rule ? formRule : existingRule
), ),
}; };
await setRulerRuleGroup(dataSourceName, location.namespace, payload); await setRulerRuleGroup(dataSourceName, namespace, payload);
return getRuleIdentifier(dataSourceName, location.namespace, location.group, formRule); 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 // 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 const payload: RulerRuleGroupDTO = targetGroup
? { ? {
@ -200,12 +200,12 @@ async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation
rules: [...targetGroup.rules, formRule], rules: [...targetGroup.rules, formRule],
} }
: { : {
name: location.group, name: group,
rules: [formRule], rules: [formRule],
}; };
await setRulerRuleGroup(dataSourceName, location.namespace, payload); await setRulerRuleGroup(dataSourceName, namespace, payload);
return getRuleIdentifier(dataSourceName, location.namespace, location.group, formRule); return getRuleIdentifier(dataSourceName, namespace, group, formRule);
} else { } else {
throw new Error('Data source and location must be specified'); throw new Error('Data source and location must be specified');
} }

@ -31,7 +31,8 @@ export interface RuleFormValues {
evaluateFor: string; evaluateFor: string;
// system alerts // system alerts
location?: { namespace: string; group: string }; namespace: string;
group: string;
forTime: number; forTime: number;
forTimeUnit: string; forTimeUnit: string;
expression: string; expression: string;

@ -29,6 +29,8 @@ export const defaultFormValues: RuleFormValues = Object.freeze({
evaluateFor: '5m', evaluateFor: '5m',
// system // system
group: '',
namespace: '',
expression: '', expression: '',
forTime: 1, forTime: 1,
forTimeUnit: 'm', forTimeUnit: 'm',
@ -114,10 +116,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
name: rule.alert, name: rule.alert,
type: RuleFormType.system, type: RuleFormType.system,
dataSourceName: ruleSourceName, dataSourceName: ruleSourceName,
location: {
namespace, namespace,
group: group.name, group: group.name,
},
expression: rule.expr, expression: rule.expr,
forTime, forTime,
forTimeUnit, forTimeUnit,

Loading…
Cancel
Save