mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
import { css } from '@emotion/css';
|
|
import { debounce, take, uniqueId } from 'lodash';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { FormProvider, useForm, useFormContext, Controller } from 'react-hook-form';
|
|
|
|
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
|
import { AsyncSelect, Box, Button, Field, Input, Label, Modal, Stack, Text, useStyles2 } from '@grafana/ui';
|
|
import appEvents from 'app/core/app_events';
|
|
import { contextSrv } from 'app/core/services/context_srv';
|
|
import { createFolder } from 'app/features/manage-dashboards/state/actions';
|
|
import { AccessControlAction, useDispatch } from 'app/types';
|
|
import { CombinedRuleGroup } from 'app/types/unified-alerting';
|
|
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
|
|
|
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
|
|
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
|
import { fetchRulerRulesAction } from '../../state/actions';
|
|
import { RuleFormValues } from '../../types/rule-form';
|
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
|
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
|
|
import { isGrafanaRulerRule } from '../../utils/rules';
|
|
import { ProvisioningBadge } from '../Provisioning';
|
|
import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal';
|
|
|
|
import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick';
|
|
import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
|
|
import { checkForPathSeparator } from './util';
|
|
|
|
export const MAX_GROUP_RESULTS = 1000;
|
|
|
|
export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => {
|
|
const dispatch = useDispatch();
|
|
|
|
// fetch the ruler rules from the database so we can figure out what other "groups" are already defined
|
|
// for our folders
|
|
useEffect(() => {
|
|
dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
|
|
}, [dispatch]);
|
|
|
|
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
|
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
|
|
|
|
const grafanaFolders = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
|
|
const folderGroups = grafanaFolders.find((f) => f.uid === folderUid)?.groups ?? [];
|
|
|
|
const groupOptions = folderGroups
|
|
.map<SelectableValue<string>>((group) => {
|
|
const isProvisioned = isProvisionedGroup(group);
|
|
return {
|
|
label: group.name,
|
|
value: group.name,
|
|
description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
|
|
// we include provisioned folders, but disable the option to select them
|
|
isDisabled: !enableProvisionedGroups ? isProvisioned : false,
|
|
isProvisioned: isProvisioned,
|
|
};
|
|
})
|
|
|
|
.sort(sortByLabel);
|
|
|
|
return { groupOptions, loading: groupfoldersForGrafana?.loading };
|
|
};
|
|
|
|
const isProvisionedGroup = (group: CombinedRuleGroup) => {
|
|
return group.rules.some(
|
|
(rule) => isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance) === true
|
|
);
|
|
};
|
|
|
|
const sortByLabel = (a: SelectableValue<string>, b: SelectableValue<string>) => {
|
|
return a.label?.localeCompare(b.label ?? '') || 0;
|
|
};
|
|
|
|
const findGroupMatchingLabel = (group: SelectableValue<string>, query: string) => {
|
|
return group.label?.toLowerCase().includes(query.toLowerCase());
|
|
};
|
|
|
|
export function FolderAndGroup({
|
|
groupfoldersForGrafana,
|
|
enableProvisionedGroups,
|
|
}: {
|
|
groupfoldersForGrafana?: RulerRulesConfigDTO | null;
|
|
enableProvisionedGroups: boolean;
|
|
}) {
|
|
const {
|
|
formState: { errors },
|
|
watch,
|
|
setValue,
|
|
control,
|
|
} = useFormContext<RuleFormValues>();
|
|
|
|
const styles = useStyles2(getStyles);
|
|
|
|
const folder = watch('folder');
|
|
const group = watch('group');
|
|
|
|
const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups);
|
|
|
|
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
|
const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false);
|
|
|
|
const onOpenFolderCreationModal = () => setIsCreatingFolder(true);
|
|
const onOpenEvaluationGroupCreationModal = () => setIsCreatingEvaluationGroup(true);
|
|
|
|
const handleFolderCreation = (folder: Folder) => {
|
|
resetGroup();
|
|
setValue('folder', folder);
|
|
setIsCreatingFolder(false);
|
|
};
|
|
|
|
const handleEvalGroupCreation = (groupName: string, evaluationInterval: string) => {
|
|
setValue('group', groupName);
|
|
setValue('evaluateEvery', evaluationInterval);
|
|
setIsCreatingEvaluationGroup(false);
|
|
};
|
|
|
|
const resetGroup = useCallback(() => {
|
|
setValue('group', '');
|
|
}, [setValue]);
|
|
|
|
const getOptions = useCallback(
|
|
async (query: string) => {
|
|
const results = query ? groupOptions.filter((group) => findGroupMatchingLabel(group, query)) : groupOptions;
|
|
return take(results, MAX_GROUP_RESULTS);
|
|
},
|
|
[groupOptions]
|
|
);
|
|
|
|
const debouncedSearch = useMemo(() => {
|
|
return debounce(getOptions, 300, { leading: true });
|
|
}, [getOptions]);
|
|
|
|
const defaultGroupValue = group ? { value: group, label: group } : undefined;
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<Stack alignItems="center">
|
|
{
|
|
<Field
|
|
label={
|
|
<Label htmlFor="folder" description={'Select a folder to store your rule.'}>
|
|
Folder
|
|
</Label>
|
|
}
|
|
className={styles.formInput}
|
|
error={errors.folder?.message}
|
|
data-testid="folder-picker"
|
|
>
|
|
<Stack direction="row" alignItems="center">
|
|
{(!isCreatingFolder && (
|
|
<>
|
|
<Controller
|
|
render={({ field: { ref, ...field } }) => (
|
|
<div style={{ width: 420 }}>
|
|
<RuleFolderPicker
|
|
inputId="folder"
|
|
invalid={!!errors.folder?.message}
|
|
{...field}
|
|
enableReset={true}
|
|
onChange={({ title, uid }) => {
|
|
field.onChange({ title, uid });
|
|
resetGroup();
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
name="folder"
|
|
rules={{
|
|
required: { value: true, message: 'Select a folder' },
|
|
validate: {
|
|
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.uid),
|
|
},
|
|
}}
|
|
/>
|
|
<Text color="secondary">or</Text>
|
|
<Button
|
|
onClick={onOpenFolderCreationModal}
|
|
type="button"
|
|
icon="plus"
|
|
fill="outline"
|
|
variant="secondary"
|
|
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
|
|
>
|
|
New folder
|
|
</Button>
|
|
</>
|
|
)) || <div>Creating new folder...</div>}
|
|
</Stack>
|
|
</Field>
|
|
}
|
|
{isCreatingFolder && (
|
|
<FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} />
|
|
)}
|
|
</Stack>
|
|
|
|
{isCreatingFolder && (
|
|
<FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} />
|
|
)}
|
|
|
|
<Stack alignItems="center">
|
|
<div style={{ width: 420 }}>
|
|
<Field
|
|
label="Evaluation group"
|
|
data-testid="group-picker"
|
|
description="Rules within the same group are evaluated concurrently over the same time interval."
|
|
className={styles.formInput}
|
|
error={errors.group?.message}
|
|
invalid={!!errors.group?.message}
|
|
>
|
|
<Controller
|
|
render={({ field: { ref, ...field }, fieldState }) => (
|
|
<AsyncSelect
|
|
disabled={!folder || loading}
|
|
inputId="group"
|
|
key={uniqueId()}
|
|
{...field}
|
|
onChange={(group) => {
|
|
field.onChange(group.label ?? '');
|
|
}}
|
|
isLoading={loading}
|
|
invalid={Boolean(folder) && !group && Boolean(fieldState.error)}
|
|
loadOptions={debouncedSearch}
|
|
cacheOptions
|
|
loadingMessage={'Loading groups...'}
|
|
defaultValue={defaultGroupValue}
|
|
defaultOptions={groupOptions}
|
|
getOptionLabel={(option: SelectableValue<string>) => (
|
|
<div>
|
|
<span>{option.label}</span>
|
|
{option['isProvisioned'] && (
|
|
<>
|
|
{' '}
|
|
<ProvisioningBadge />
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
placeholder={'Select an evaluation group...'}
|
|
/>
|
|
)}
|
|
name="group"
|
|
control={control}
|
|
rules={{
|
|
required: { value: true, message: 'Must enter a group name' },
|
|
validate: {
|
|
pathSeparator: (group_: string) => checkForPathSeparator(group_),
|
|
},
|
|
}}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Box marginTop={4} gap={1} display={'flex'} alignItems={'center'}>
|
|
<Text color="secondary">or</Text>
|
|
<Button
|
|
onClick={onOpenEvaluationGroupCreationModal}
|
|
type="button"
|
|
icon="plus"
|
|
fill="outline"
|
|
variant="secondary"
|
|
disabled={!folder}
|
|
>
|
|
New evaluation group
|
|
</Button>
|
|
</Box>
|
|
{isCreatingEvaluationGroup && (
|
|
<EvaluationGroupCreationModal
|
|
onCreate={handleEvalGroupCreation}
|
|
onClose={() => setIsCreatingEvaluationGroup(false)}
|
|
groupfoldersForGrafana={groupfoldersForGrafana}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FolderCreationModal({
|
|
onClose,
|
|
onCreate,
|
|
}: {
|
|
onClose: () => void;
|
|
onCreate: (folder: Folder) => void;
|
|
}): React.ReactElement {
|
|
const styles = useStyles2(getStyles);
|
|
|
|
const [title, setTitle] = useState('');
|
|
const onSubmit = async () => {
|
|
const newFolder = await createFolder({ title: title });
|
|
if (!newFolder.uid) {
|
|
appEvents.emit(AppEvents.alertError, ['Folder could not be created']);
|
|
return;
|
|
}
|
|
|
|
const folder: Folder = { title: newFolder.title, uid: newFolder.uid };
|
|
onCreate(folder);
|
|
appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']);
|
|
};
|
|
|
|
const error = containsSlashes(title);
|
|
|
|
return (
|
|
<Modal className={styles.modal} isOpen={true} title={'New folder'} onDismiss={onClose} onClickBackdrop={onClose}>
|
|
<div className={styles.modalTitle}>Create a new folder to store your rule</div>
|
|
|
|
<form onSubmit={onSubmit}>
|
|
<Field
|
|
label={<Label htmlFor="folder">Folder name</Label>}
|
|
error={"The folder name can't contain slashes"}
|
|
invalid={error}
|
|
>
|
|
<Input
|
|
autoFocus={true}
|
|
id="folderName"
|
|
placeholder="Enter a name"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.currentTarget.value)}
|
|
className={styles.formInput}
|
|
/>
|
|
</Field>
|
|
|
|
<Modal.ButtonRow>
|
|
<Button variant="secondary" type="button" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={!title || error}>
|
|
Create
|
|
</Button>
|
|
</Modal.ButtonRow>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function EvaluationGroupCreationModal({
|
|
onClose,
|
|
onCreate,
|
|
groupfoldersForGrafana,
|
|
}: {
|
|
onClose: () => void;
|
|
onCreate: (group: string, evaluationInterval: string) => void;
|
|
groupfoldersForGrafana?: RulerRulesConfigDTO | null;
|
|
}): React.ReactElement {
|
|
const styles = useStyles2(getStyles);
|
|
const onSubmit = () => {
|
|
onCreate(getValues('group'), getValues('evaluateEvery'));
|
|
};
|
|
|
|
const { watch } = useFormContext<RuleFormValues>();
|
|
|
|
const evaluateEveryId = 'eval-every-input';
|
|
const [groupName, folderName] = watch(['group', 'folder.title']);
|
|
|
|
const groupRules =
|
|
(groupfoldersForGrafana && groupfoldersForGrafana[folderName]?.find((g) => g.name === groupName)?.rules) ?? [];
|
|
|
|
const onCancel = () => {
|
|
onClose();
|
|
};
|
|
|
|
const formAPI = useForm({
|
|
defaultValues: { group: '', evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL },
|
|
mode: 'onChange',
|
|
shouldFocusError: true,
|
|
});
|
|
|
|
const { register, handleSubmit, formState, setValue, getValues, watch: watchGroupFormValues } = formAPI;
|
|
const evaluationInterval = watchGroupFormValues('evaluateEvery');
|
|
|
|
const setEvaluationInterval = (interval: string) => {
|
|
setValue('evaluateEvery', interval, { shouldValidate: true });
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
className={styles.modal}
|
|
isOpen={true}
|
|
title={'New evaluation group'}
|
|
onDismiss={onCancel}
|
|
onClickBackdrop={onCancel}
|
|
>
|
|
<div className={styles.modalTitle}>Create a new evaluation group to use for this alert rule.</div>
|
|
|
|
<FormProvider {...formAPI}>
|
|
<form onSubmit={handleSubmit(() => onSubmit())}>
|
|
<Field
|
|
label={<Label htmlFor={'group'}>Evaluation group name</Label>}
|
|
error={formState.errors.group?.message}
|
|
invalid={Boolean(formState.errors.group)}
|
|
>
|
|
<Input
|
|
className={styles.formInput}
|
|
autoFocus={true}
|
|
id={'group'}
|
|
placeholder="Enter a name"
|
|
{...register('group', { required: { value: true, message: 'Required.' } })}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
error={formState.errors.evaluateEvery?.message}
|
|
invalid={Boolean(formState.errors.evaluateEvery) ? true : undefined}
|
|
label={
|
|
<Label htmlFor={evaluateEveryId} description="How often all rules in the group are evaluated.">
|
|
Evaluation interval
|
|
</Label>
|
|
}
|
|
>
|
|
<Stack direction="column">
|
|
<Input
|
|
className={styles.formInput}
|
|
id={evaluateEveryId}
|
|
placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL}
|
|
{...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))}
|
|
/>
|
|
<Stack direction="row" alignItems="flex-end">
|
|
<EvaluationGroupQuickPick currentInterval={evaluationInterval} onSelect={setEvaluationInterval} />
|
|
</Stack>
|
|
</Stack>
|
|
</Field>
|
|
<Modal.ButtonRow>
|
|
<Button variant="secondary" type="button" onClick={onCancel}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={!formState.isValid}>
|
|
Create
|
|
</Button>
|
|
</Modal.ButtonRow>
|
|
</form>
|
|
</FormProvider>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const getStyles = (theme: GrafanaTheme2) => ({
|
|
container: css`
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: baseline;
|
|
max-width: ${theme.breakpoints.values.lg}px;
|
|
justify-content: space-between;
|
|
`,
|
|
formInput: css`
|
|
flex-grow: 1;
|
|
`,
|
|
modal: css`
|
|
width: ${theme.breakpoints.values.sm}px;
|
|
`,
|
|
modalTitle: css`
|
|
color: ${theme.colors.text.secondary};
|
|
margin-bottom: ${theme.spacing(2)};
|
|
`,
|
|
});
|