mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
import { css, cx } from '@emotion/css';
|
|
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useFieldArray, UseFieldArrayAppend, useFormContext, Controller } from 'react-hook-form';
|
|
|
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
|
import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui';
|
|
import { useDispatch } from 'app/types';
|
|
|
|
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
|
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
|
|
import { RuleFormValues } from '../../types/rule-form';
|
|
import AlertLabelDropdown from '../AlertLabelDropdown';
|
|
|
|
import { NeedHelpInfo } from './NeedHelpInfo';
|
|
|
|
interface Props {
|
|
className?: string;
|
|
dataSourceName?: string | null;
|
|
}
|
|
|
|
const useGetCustomLabels = (dataSourceName: string): { loading: boolean; labelsByKey: Record<string, Set<string>> } => {
|
|
const dispatch = useDispatch();
|
|
|
|
useEffect(() => {
|
|
dispatch(fetchRulerRulesIfNotFetchedYet(dataSourceName));
|
|
}, [dispatch, dataSourceName]);
|
|
|
|
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
|
const rulerRequest = rulerRuleRequests[dataSourceName];
|
|
|
|
const labelsByKeyResult = useMemo<Record<string, Set<string>>>(() => {
|
|
const labelsByKey: Record<string, Set<string>> = {};
|
|
|
|
const rulerRulesConfig = rulerRequest?.result;
|
|
if (!rulerRulesConfig) {
|
|
return labelsByKey;
|
|
}
|
|
|
|
const allRules = Object.values(rulerRulesConfig)
|
|
.flatMap((groups) => groups)
|
|
.flatMap((group) => group.rules);
|
|
|
|
allRules.forEach((rule) => {
|
|
if (rule.labels) {
|
|
Object.entries(rule.labels).forEach(([key, value]) => {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
const labelEntry = labelsByKey[key];
|
|
if (labelEntry) {
|
|
labelEntry.add(value);
|
|
} else {
|
|
labelsByKey[key] = new Set([value]);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return labelsByKey;
|
|
}, [rulerRequest]);
|
|
|
|
return { loading: rulerRequest?.loading, labelsByKey: labelsByKeyResult };
|
|
};
|
|
|
|
function mapLabelsToOptions(items: Iterable<string> = []): Array<SelectableValue<string>> {
|
|
return Array.from(items, (item) => ({ label: item, value: item }));
|
|
}
|
|
|
|
const RemoveButton: FC<{
|
|
remove: (index?: number | number[] | undefined) => void;
|
|
className: string;
|
|
index: number;
|
|
}> = ({ remove, className, index }) => (
|
|
<Button
|
|
className={className}
|
|
aria-label="delete label"
|
|
icon="trash-alt"
|
|
data-testid={`delete-label-${index}`}
|
|
variant="secondary"
|
|
onClick={() => {
|
|
remove(index);
|
|
}}
|
|
/>
|
|
);
|
|
|
|
const AddButton: FC<{
|
|
append: UseFieldArrayAppend<RuleFormValues, 'labels'>;
|
|
className: string;
|
|
}> = ({ append, className }) => (
|
|
<Button
|
|
className={className}
|
|
icon="plus-circle"
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
append({ key: '', value: '' });
|
|
}}
|
|
>
|
|
Add label
|
|
</Button>
|
|
);
|
|
|
|
const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName }) => {
|
|
const styles = useStyles2(getStyles);
|
|
const {
|
|
control,
|
|
watch,
|
|
formState: { errors },
|
|
} = useFormContext<RuleFormValues>();
|
|
|
|
const labels = watch('labels');
|
|
const { fields, remove, append } = useFieldArray({ control, name: 'labels' });
|
|
|
|
const { loading, labelsByKey } = useGetCustomLabels(dataSourceName);
|
|
|
|
const [selectedKey, setSelectedKey] = useState('');
|
|
|
|
const keys = useMemo(() => {
|
|
return mapLabelsToOptions(Object.keys(labelsByKey));
|
|
}, [labelsByKey]);
|
|
|
|
const getValuesForLabel = useCallback(
|
|
(key: string) => {
|
|
return mapLabelsToOptions(labelsByKey[key]);
|
|
},
|
|
[labelsByKey]
|
|
);
|
|
|
|
const values = useMemo(() => {
|
|
return getValuesForLabel(selectedKey);
|
|
}, [selectedKey, getValuesForLabel]);
|
|
|
|
return (
|
|
<>
|
|
{loading && <LoadingPlaceholder text="Loading" />}
|
|
{!loading && (
|
|
<Stack direction="column" gap={0.5}>
|
|
{fields.map((field, index) => {
|
|
return (
|
|
<div key={field.id}>
|
|
<div className={cx(styles.flexRow, styles.centerAlignRow)}>
|
|
<Field
|
|
className={styles.labelInput}
|
|
invalid={Boolean(errors.labels?.[index]?.key?.message)}
|
|
error={errors.labels?.[index]?.key?.message}
|
|
data-testid={`label-key-${index}`}
|
|
>
|
|
<Controller
|
|
name={`labels.${index}.key`}
|
|
control={control}
|
|
rules={{ required: Boolean(labels[index]?.value) ? 'Required.' : false }}
|
|
render={({ field: { onChange, ref, ...rest } }) => {
|
|
return (
|
|
<AlertLabelDropdown
|
|
{...rest}
|
|
defaultValue={field.key ? { label: field.key, value: field.key } : undefined}
|
|
options={keys}
|
|
onChange={(newValue: SelectableValue) => {
|
|
onChange(newValue.value);
|
|
setSelectedKey(newValue.value);
|
|
}}
|
|
type="key"
|
|
/>
|
|
);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
|
<Field
|
|
className={styles.labelInput}
|
|
invalid={Boolean(errors.labels?.[index]?.value?.message)}
|
|
error={errors.labels?.[index]?.value?.message}
|
|
data-testid={`label-value-${index}`}
|
|
>
|
|
<Controller
|
|
control={control}
|
|
name={`labels.${index}.value`}
|
|
rules={{ required: Boolean(labels[index]?.value) ? 'Required.' : false }}
|
|
render={({ field: { onChange, ref, ...rest } }) => {
|
|
return (
|
|
<AlertLabelDropdown
|
|
{...rest}
|
|
defaultValue={field.value ? { label: field.value, value: field.value } : undefined}
|
|
options={values}
|
|
onChange={(newValue: SelectableValue) => {
|
|
onChange(newValue.value);
|
|
}}
|
|
onOpenMenu={() => {
|
|
setSelectedKey(labels[index].key);
|
|
}}
|
|
type="value"
|
|
/>
|
|
);
|
|
}}
|
|
/>
|
|
</Field>
|
|
|
|
<RemoveButton className={styles.deleteLabelButton} index={index} remove={remove} />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
<AddButton className={styles.addLabelButton} append={append} />
|
|
</Stack>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const LabelsWithoutSuggestions: FC = () => {
|
|
const styles = useStyles2(getStyles);
|
|
const {
|
|
register,
|
|
control,
|
|
watch,
|
|
formState: { errors },
|
|
} = useFormContext<RuleFormValues>();
|
|
|
|
const labels = watch('labels');
|
|
const { fields, remove, append } = useFieldArray({ control, name: 'labels' });
|
|
|
|
return (
|
|
<>
|
|
{fields.map((field, index) => {
|
|
return (
|
|
<div key={field.id}>
|
|
<div className={cx(styles.flexRow, styles.centerAlignRow)} data-testid="alertlabel-input-wrapper">
|
|
<Field
|
|
className={styles.labelInput}
|
|
invalid={!!errors.labels?.[index]?.key?.message}
|
|
error={errors.labels?.[index]?.key?.message}
|
|
>
|
|
<Input
|
|
{...register(`labels.${index}.key`, {
|
|
required: { value: !!labels[index]?.value, message: 'Required.' },
|
|
})}
|
|
placeholder="key"
|
|
data-testid={`label-key-${index}`}
|
|
defaultValue={field.key}
|
|
/>
|
|
</Field>
|
|
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
|
<Field
|
|
className={styles.labelInput}
|
|
invalid={!!errors.labels?.[index]?.value?.message}
|
|
error={errors.labels?.[index]?.value?.message}
|
|
>
|
|
<Input
|
|
{...register(`labels.${index}.value`, {
|
|
required: { value: !!labels[index]?.key, message: 'Required.' },
|
|
})}
|
|
placeholder="value"
|
|
data-testid={`label-value-${index}`}
|
|
defaultValue={field.value}
|
|
/>
|
|
</Field>
|
|
<RemoveButton className={styles.deleteLabelButton} index={index} remove={remove} />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
<AddButton className={styles.addLabelButton} append={append} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
const LabelsField: FC<Props> = ({ dataSourceName }) => {
|
|
const styles = useStyles2(getStyles);
|
|
|
|
return (
|
|
<div>
|
|
<Stack direction="column" gap={1}>
|
|
<Text element="h5">Labels</Text>
|
|
<Stack direction={'row'} gap={1}>
|
|
<Text variant="bodySmall" color="secondary">
|
|
Add labels to your rule for searching, silencing, or routing to a notification policy.
|
|
</Text>
|
|
<NeedHelpInfo
|
|
contentText="The dropdown only displays labels that you have previously used for alerts.
|
|
Select a label from the options below or type in a new one."
|
|
title="Labels"
|
|
/>
|
|
</Stack>
|
|
</Stack>
|
|
<div className={styles.labelsContainer}></div>
|
|
{dataSourceName ? <LabelsWithSuggestions dataSourceName={dataSourceName} /> : <LabelsWithoutSuggestions />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getStyles = (theme: GrafanaTheme2) => {
|
|
return {
|
|
icon: css({
|
|
marginRight: theme.spacing(0.5),
|
|
}),
|
|
flexColumn: css({
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}),
|
|
flexRow: css({
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-start',
|
|
'& + button': {
|
|
marginLeft: theme.spacing(0.5),
|
|
},
|
|
}),
|
|
deleteLabelButton: css({
|
|
marginLeft: theme.spacing(0.5),
|
|
alignSelf: 'flex-start',
|
|
}),
|
|
addLabelButton: css({
|
|
flexGrow: 0,
|
|
alignSelf: 'flex-start',
|
|
}),
|
|
centerAlignRow: css({
|
|
alignItems: 'baseline',
|
|
}),
|
|
equalSign: css({
|
|
alignSelf: 'flex-start',
|
|
width: '28px',
|
|
justifyContent: 'center',
|
|
marginLeft: theme.spacing(0.5),
|
|
}),
|
|
labelInput: css({
|
|
width: '175px',
|
|
marginBottom: `-${theme.spacing(1)}`,
|
|
'& + &': {
|
|
marginLeft: theme.spacing(1),
|
|
},
|
|
}),
|
|
labelsContainer: css({
|
|
marginBottom: theme.spacing(3),
|
|
}),
|
|
};
|
|
};
|
|
|
|
export default LabelsField;
|