From 282c62d8bf2ded3850f8165b390302a2c9cacb34 Mon Sep 17 00:00:00 2001 From: Domas Date: Wed, 14 Apr 2021 15:57:36 +0300 Subject: [PATCH] Alerting: Rule edit form (#32877) --- .gitignore | 1 + .../src/components/DataSourcePicker.tsx | 8 +- .../src/services/dataSourceSrv.ts | 6 + .../src/components/Select/Select.tsx | 2 +- .../core/components/Select/FolderPicker.tsx | 23 +- .../__snapshots__/FolderPicker.test.tsx.snap | 4 +- public/app/core/hooks/useAsyncAction.ts | 0 .../features/alerting/unified/RuleEditor.tsx | 6 + .../alerting/unified/RuleList.test.tsx | 4 +- .../features/alerting/unified/RuleList.tsx | 6 +- .../features/alerting/unified/api/ruler.ts | 13 +- .../{RuleQuery.tsx => Expression.tsx} | 4 +- .../unified/components/RuleGroupPicker.tsx | 62 +++++ .../rule-editor/AlertConditionsSection.tsx | 53 ---- .../components/rule-editor/AlertDetails.tsx | 17 -- .../components/rule-editor/AlertRuleForm.tsx | 237 ++++++++++-------- .../rule-editor/AlertTypeSection.tsx | 149 ----------- .../components/rule-editor/AlertTypeStep.tsx | 175 +++++++++++++ .../rule-editor/AnnotationKeyInput.tsx | 63 +++++ .../rule-editor/AnnotationsField.tsx | 157 ++++++------ .../components/rule-editor/ConditionField.tsx | 54 ++++ .../components/rule-editor/ConditionsStep.tsx | 149 +++++++++++ .../components/rule-editor/DetailsStep.tsx | 17 ++ .../components/rule-editor/Expression.tsx | 17 -- .../rule-editor/ExpressionEditor.tsx | 19 ++ .../rule-editor/GrafanaAlertStatePicker.tsx | 16 ++ .../rule-editor/GrafanaQueryEditor.tsx | 27 ++ .../components/rule-editor/LabelsField.tsx | 68 +++-- .../components/rule-editor/QueryStep.tsx | 47 ++++ .../rule-editor/RuleEditorSection.tsx | 53 ++++ .../rule-editor/RuleFolderPicker.tsx | 15 ++ .../unified/components/rules/RuleDetails.tsx | 4 +- .../unified/components/rules/RuleQuery.tsx | 23 ++ .../unified/components/rules/RulesGroup.tsx | 7 +- .../hooks/useCombinedRuleNamespaces.ts | 25 +- .../unified/hooks/useRuleSourcesWithRuler.ts | 24 ++ .../alerting/unified/mocks/grafana-queries.ts | 59 +++++ .../alerting/unified/state/actions.ts | 99 +++++++- .../alerting/unified/state/reducers.ts | 6 +- .../alerting/unified/types/rule-form.ts | 38 +++ .../alerting/unified/utils/datasource.ts | 6 + .../features/alerting/unified/utils/misc.ts | 7 + .../alerting/unified/utils/rule-form.ts | 39 +++ .../features/alerting/unified/utils/rules.ts | 5 + public/app/features/plugins/datasource_srv.ts | 8 +- public/app/routes/routes.tsx | 10 +- public/app/types/unified-alerting-dto.ts | 45 +++- 47 files changed, 1378 insertions(+), 499 deletions(-) create mode 100644 public/app/core/hooks/useAsyncAction.ts create mode 100644 public/app/features/alerting/unified/RuleEditor.tsx rename public/app/features/alerting/unified/components/{RuleQuery.tsx => Expression.tsx} (94%) create mode 100644 public/app/features/alerting/unified/components/RuleGroupPicker.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-editor/AlertConditionsSection.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-editor/AlertDetails.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-editor/AlertTypeSection.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/ConditionsStep.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-editor/Expression.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/GrafanaAlertStatePicker.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/GrafanaQueryEditor.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/QueryStep.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleQuery.tsx create mode 100644 public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts create mode 100644 public/app/features/alerting/unified/mocks/grafana-queries.ts create mode 100644 public/app/features/alerting/unified/types/rule-form.ts create mode 100644 public/app/features/alerting/unified/utils/rule-form.ts diff --git a/.gitignore b/.gitignore index 4ff100296a1..7bcfa433e21 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ awsconfig .yarn/ vendor/ /docs/menu.yaml +/requests # Enterprise emails /emails/templates/enterprise_* diff --git a/packages/grafana-runtime/src/components/DataSourcePicker.tsx b/packages/grafana-runtime/src/components/DataSourcePicker.tsx index 1fcf707ba63..82733b654f5 100644 --- a/packages/grafana-runtime/src/components/DataSourcePicker.tsx +++ b/packages/grafana-runtime/src/components/DataSourcePicker.tsx @@ -26,9 +26,11 @@ export interface DataSourcePickerProps { metrics?: boolean; annotations?: boolean; variables?: boolean; + alerting?: boolean; pluginId?: string; noDefault?: boolean; width?: number; + filter?: (dataSource: DataSourceInstanceSettings) => boolean; } /** @@ -106,7 +108,7 @@ export class DataSourcePicker extends PureComponent ({ value: ds.name, @@ -149,7 +153,7 @@ export class DataSourcePicker extends PureComponent { if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) { diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index 25094299e9e..9dde93d8566 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -40,6 +40,9 @@ export interface GetDataSourceListFilters { /** Only return data sources that support annotations */ annotations?: boolean; + /** Only filter data sources that support alerting */ + alerting?: boolean; + /** * By default only data sources that can be queried will be returned. Meaning they have tracing, * metrics, logs or annotations flag set in plugin.json file @@ -54,6 +57,9 @@ export interface GetDataSourceListFilters { /** filter list by plugin */ pluginId?: string; + + /** apply a function to filter */ + filter?: (dataSource: DataSourceInstanceSettings) => boolean; } let singletonInstance: DataSourceSrv; diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index f082d2e443f..81104c38bcb 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -14,7 +14,7 @@ export function MultiSelect(props: MultiSelectCommonProps) { interface AsyncSelectProps extends Omit, 'options'>, SelectAsyncProps { // AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options - value?: SelectableValue; + value?: SelectableValue | null; invalid?: boolean; } diff --git a/public/app/core/components/Select/FolderPicker.tsx b/public/app/core/components/Select/FolderPicker.tsx index 25c8f97c8d3..eb3c62d6091 100644 --- a/public/app/core/components/Select/FolderPicker.tsx +++ b/public/app/core/components/Select/FolderPicker.tsx @@ -19,10 +19,12 @@ export interface Props { initialTitle?: string; initialFolderId?: number; permissionLevel?: 'View' | 'Edit'; + allowEmpty?: boolean; + showRoot?: boolean; } interface State { - folder: SelectableValue; + folder: SelectableValue | null; } export class FolderPicker extends PureComponent { @@ -32,7 +34,7 @@ export class FolderPicker extends PureComponent { super(props); this.state = { - folder: {}, + folder: null, }; this.debouncedSearch = debounce(this.getOptions, 300, { @@ -47,6 +49,8 @@ export class FolderPicker extends PureComponent { initialTitle: '', enableCreateNew: false, permissionLevel: 'Edit', + allowEmpty: false, + showRoot: true, }; componentDidMount = async () => { @@ -54,7 +58,7 @@ export class FolderPicker extends PureComponent { }; getOptions = async (query: string) => { - const { rootName, enableReset, initialTitle, permissionLevel } = this.props; + const { rootName, enableReset, initialTitle, permissionLevel, showRoot } = this.props; const params = { query, type: 'dash-folder', @@ -64,8 +68,9 @@ export class FolderPicker extends PureComponent { // TODO: move search to BackendSrv interface // @ts-ignore const searchHits = (await getBackendSrv().search(params)) as DashboardSearchHit[]; + const options: Array> = searchHits.map((hit) => ({ label: hit.title, value: hit.id })); - if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase())) { + if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) { options.unshift({ label: rootName, value: 0 }); } @@ -111,15 +116,15 @@ export class FolderPicker extends PureComponent { const options = await this.getOptions(''); - let folder: SelectableValue = { value: -1 }; + let folder: SelectableValue | null = null; if (initialFolderId !== undefined && initialFolderId !== null && initialFolderId > -1) { - folder = options.find((option) => option.value === initialFolderId) || { value: -1 }; + folder = options.find((option) => option.value === initialFolderId) || null; } else if (enableReset && initialTitle) { folder = resetFolder; } - if (folder.value === -1) { + if (!folder && !this.props.allowEmpty) { if (contextSrv.isEditor) { folder = rootFolder; } else { @@ -139,8 +144,8 @@ export class FolderPicker extends PureComponent { }, () => { // if this is not the same as our initial value notify parent - if (folder.value !== initialFolderId) { - this.props.onChange({ id: folder.value!, title: folder.text }); + if (folder && folder.value !== initialFolderId) { + this.props.onChange({ id: folder.value!, title: folder.label! }); } } ); diff --git a/public/app/core/components/Select/__snapshots__/FolderPicker.test.tsx.snap b/public/app/core/components/Select/__snapshots__/FolderPicker.test.tsx.snap index b8cef019ee4..064c269b24e 100644 --- a/public/app/core/components/Select/__snapshots__/FolderPicker.test.tsx.snap +++ b/public/app/core/components/Select/__snapshots__/FolderPicker.test.tsx.snap @@ -7,12 +7,12 @@ exports[`FolderPicker should render 1`] = ` `; diff --git a/public/app/core/hooks/useAsyncAction.ts b/public/app/core/hooks/useAsyncAction.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx new file mode 100644 index 00000000000..44aaa6e47f6 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -0,0 +1,6 @@ +import React, { FC } from 'react'; +import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; + +const RuleEditor: FC = () => ; + +export default RuleEditor; diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 4d9f0f7b9a0..f70e5170f9b 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -113,7 +113,7 @@ describe('RuleList', () => { } else if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { return Promise.resolve([ mockPromRuleNamespace({ - name: '', + name: 'foofolder', dataSourceName: GRAFANA_RULES_SOURCE_NAME, groups: [ mockPromRuleGroup({ @@ -132,7 +132,7 @@ describe('RuleList', () => { const groups = await ui.ruleGroup.findAll(); expect(groups).toHaveLength(5); - expect(groups[0]).toHaveTextContent('grafana-group'); + expect(groups[0]).toHaveTextContent('foofolder'); expect(groups[1]).toHaveTextContent('default > group-1'); expect(groups[2]).toHaveTextContent('default > group-1'); expect(groups[3]).toHaveTextContent('default > group-2'); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index b1abda9ed87..a9af51b5ad9 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -7,7 +7,7 @@ import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { NoRulesSplash } from './components/rules/NoRulesCTA'; import { SystemOrApplicationRules } from './components/rules/SystemOrApplicationRules'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchAllPromAndRulerRules } from './state/actions'; +import { fetchAllPromAndRulerRulesAction } from './state/actions'; import { getAllRulesSourceNames, getRulesDataSources, @@ -27,8 +27,8 @@ export const RuleList: FC = () => { // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS useEffect(() => { - dispatch(fetchAllPromAndRulerRules()); - const interval = setInterval(() => dispatch(fetchAllPromAndRulerRules()), RULE_LIST_POLL_INTERVAL_MS); + dispatch(fetchAllPromAndRulerRulesAction()); + const interval = setInterval(() => dispatch(fetchAllPromAndRulerRulesAction()), RULE_LIST_POLL_INTERVAL_MS); return () => { clearInterval(interval); }; diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index 656876f19d2..4135d5241c0 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -9,10 +9,15 @@ export async function setRulerRuleGroup( namespace: string, group: RulerRuleGroupDTO ): Promise { - await getBackendSrv().post( - `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, - group - ); + await await getBackendSrv() + .fetch({ + method: 'POST', + url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + data: group, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); } // fetch all ruler rule namespaces and included groups diff --git a/public/app/features/alerting/unified/components/RuleQuery.tsx b/public/app/features/alerting/unified/components/Expression.tsx similarity index 94% rename from public/app/features/alerting/unified/components/RuleQuery.tsx rename to public/app/features/alerting/unified/components/Expression.tsx index 9ab0c873ebf..f04683fc7a5 100644 --- a/public/app/features/alerting/unified/components/RuleQuery.tsx +++ b/public/app/features/alerting/unified/components/Expression.tsx @@ -11,7 +11,7 @@ import { DataSourceType, isCloudRulesSource } from '../utils/datasource'; import { Well } from './Well'; interface Props { - query: string; + expression: string; rulesSource: RulesSource; } @@ -34,7 +34,7 @@ export const HighlightedQuery: FC<{ language: 'promql' | 'logql'; expr: string } return ; }; -export const RuleQuery: FC = ({ query, rulesSource }) => { +export const Expression: FC = ({ expression: query, rulesSource }) => { const styles = useStyles(getStyles); return ( diff --git a/public/app/features/alerting/unified/components/RuleGroupPicker.tsx b/public/app/features/alerting/unified/components/RuleGroupPicker.tsx new file mode 100644 index 00000000000..e01bfec7961 --- /dev/null +++ b/public/app/features/alerting/unified/components/RuleGroupPicker.tsx @@ -0,0 +1,62 @@ +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 = ({ 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]); + + return ( + { + console.log('selected', value); + onChange(parseValue(value)); + }} + initialValue={value ? stringifyValue(value) : undefined} + displayAllSelectedLevels={true} + separator=" > " + options={options} + changeOnSelect={false} + /> + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertConditionsSection.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertConditionsSection.tsx deleted file mode 100644 index 1d611d081e1..00000000000 --- a/public/app/features/alerting/unified/components/rule-editor/AlertConditionsSection.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { FC } from 'react'; -import { Field, FieldSet, Input, Select, useStyles, Label, InputControl } from '@grafana/ui'; -import { css } from '@emotion/css'; -import { GrafanaTheme } from '@grafana/data'; -import { AlertRuleFormMethods } from './AlertRuleForm'; - -type Props = AlertRuleFormMethods; - -enum TIME_OPTIONS { - seconds = 's', - minutes = 'm', - hours = 'h', - days = 'd', -} - -const timeOptions = Object.entries(TIME_OPTIONS).map(([key, value]) => ({ - label: key, - value: value, -})); - -const getStyles = (theme: GrafanaTheme) => ({ - flexRow: css` - display: flex; - flex-direction: row; - align-items: flex-end; - justify-content: flex-start; - `, - numberInput: css` - width: 200px; - & + & { - margin-left: ${theme.spacing.sm}; - } - `, -}); - -const AlertConditionsSection: FC = ({ register, control }) => { - const styles = useStyles(getStyles); - return ( -
- -
- - - - - - -
-
- ); -}; - -export default AlertConditionsSection; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertDetails.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertDetails.tsx deleted file mode 100644 index 95978ea5ea5..00000000000 --- a/public/app/features/alerting/unified/components/rule-editor/AlertDetails.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React, { FC } from 'react'; -import { FieldSet, FormAPI } from '@grafana/ui'; -import LabelsField from './LabelsField'; -import AnnotationsField from './AnnotationsField'; - -interface Props extends FormAPI<{}> {} - -const AlertDetails: FC = (props) => { - return ( -
- - -
- ); -}; - -export default AlertDetails; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index 262e62c7402..04a3d94a84f 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -1,41 +1,145 @@ -import React, { FC, useState } from 'react'; -import { GrafanaTheme, SelectableValue } from '@grafana/data'; -import { PageToolbar, ToolbarButton, stylesFactory, Form, FormAPI } from '@grafana/ui'; +import React, { FC, useEffect } from 'react'; +import { GrafanaTheme } from '@grafana/data'; +import { PageToolbar, ToolbarButton, useStyles, CustomScrollbar, Spinner, Alert } from '@grafana/ui'; import { css } from '@emotion/css'; -import { config } from 'app/core/config'; -import AlertTypeSection from './AlertTypeSection'; -import AlertConditionsSection from './AlertConditionsSection'; -import AlertDetails from './AlertDetails'; -import Expression from './Expression'; +import { AlertTypeStep } from './AlertTypeStep'; +import { ConditionsStep } from './ConditionsStep'; +import { DetailsStep } from './DetailsStep'; +import { QueryStep } from './QueryStep'; +import { useForm, FormContext } from 'react-hook-form'; -import { fetchRulerRulesNamespace, setRulerRuleGroup } from '../../api/ruler'; -import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; -import { locationService } from '@grafana/runtime'; +import { GrafanaAlertState } from 'app/types/unified-alerting-dto'; +//import { locationService } from '@grafana/runtime'; +import { RuleFormValues } from '../../types/rule-form'; +import { SAMPLE_QUERIES } from '../../mocks/grafana-queries'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { initialAsyncRequestState } from '../../utils/redux'; +import { useDispatch } from 'react-redux'; +import { saveRuleFormAction } from '../../state/actions'; +import { cleanUpAction } from 'app/core/actions/cleanUp'; type Props = {}; -interface AlertRuleFormFields { - name: string; - type: SelectableValue; - folder: SelectableValue; - forTime: string; - dataSource: SelectableValue; - expression: string; - timeUnit: SelectableValue; - labels: Array<{ key: string; value: string }>; - annotations: Array<{ key: SelectableValue; value: string }>; -} +const defaultValues: RuleFormValues = Object.freeze({ + name: '', + labels: [{ key: '', value: '' }], + annotations: [{ key: '', value: '' }], + dataSourceName: null, -export type AlertRuleFormMethods = FormAPI; + // threshold + folder: null, + queries: SAMPLE_QUERIES, // @TODO remove the sample eventually + condition: '', + noDataState: GrafanaAlertState.NoData, + execErrState: GrafanaAlertState.Alerting, + evaluateEvery: '1m', + evaluateFor: '5m', -const getStyles = stylesFactory((theme: GrafanaTheme) => { + // system + expression: '', + forTime: 1, + forTimeUnit: 'm', +}); + +export const AlertRuleForm: FC = () => { + const styles = useStyles(getStyles); + const dispatch = useDispatch(); + + useEffect(() => { + return () => { + dispatch(cleanUpAction({ stateSelector: (state) => state.unifiedAlerting.ruleForm })); + }; + }, [dispatch]); + + const formAPI = useForm({ + mode: 'onSubmit', + defaultValues, + }); + + const { handleSubmit, watch } = formAPI; + + const type = watch('type'); + const dataSourceName = watch('dataSourceName'); + + const showStep2 = Boolean(dataSourceName && type); + + const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState; + + const submit = (values: RuleFormValues) => { + dispatch( + saveRuleFormAction({ + ...values, + annotations: values.annotations.filter(({ key }) => !!key), + labels: values.labels.filter(({ key }) => !!key), + }) + ); + }; + + return ( + +
+ + + Cancel + + + {submitState.loading && } + Save + + + {submitState.loading && } + Save and exit + + +
+ +
+ {submitState.error && ( + + {submitState.error.message || (submitState.error as any)?.data?.message || String(submitState.error)} + + )} + + {showStep2 && ( + <> + + + + + )} +
+
+
+
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { return { - fullWidth: css` - width: 100%; + buttonSpiner: css` + margin-right: ${theme.spacing.sm}; `, - formWrapper: css` - padding: 0 ${theme.spacing.md}; + toolbar: css` + padding-top: ${theme.spacing.sm}; + padding-bottom: ${theme.spacing.md}; + border-bottom: solid 1px ${theme.colors.border2}; + `, + form: css` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + `, + contentInner: css` + flex: 1; + padding: ${theme.spacing.md}; + `, + contentOutter: css` + background: ${theme.colors.panelBg}; + overflow: hidden; + flex: 1; `, formInput: css` width: 400px; @@ -49,81 +153,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { justify-content: flex-start; `, }; -}); - -const AlertRuleForm: FC = () => { - const styles = getStyles(config.theme); - - const [folder, setFolder] = useState<{ namespace: string; group: string }>(); - - const handleSubmit = (alertRule: AlertRuleFormFields) => { - const { name, expression, forTime, dataSource, timeUnit, labels, annotations } = alertRule; - console.log('saving', alertRule); - const { namespace, group: groupName } = folder || {}; - if (namespace && groupName) { - fetchRulerRulesNamespace(dataSource?.value, namespace) - .then((ruleGroup) => { - const group: RulerRuleGroupDTO = ruleGroup.find(({ name }) => name === groupName) || { - name: groupName, - rules: [] as RulerRuleDTO[], - }; - const alertRule: RulerRuleDTO = { - alert: name, - expr: expression, - for: `${forTime}${timeUnit.value}`, - labels: labels.reduce((acc, { key, value }) => { - if (key && value) { - acc[key] = value; - } - return acc; - }, {} as Record), - annotations: annotations.reduce((acc, { key, value }) => { - if (key && value) { - acc[key.value] = value; - } - return acc; - }, {} as Record), - }; - - group.rules = group?.rules.concat(alertRule); - return setRulerRuleGroup(dataSource?.value, namespace, group); - }) - .then(() => { - console.log('Alert rule saved successfully'); - locationService.push('/alerting/list'); - }) - .catch((error) => console.error(error)); - } - }; - return ( -
- {(formApi) => ( - <> - - - Save - - Save and exit - - - Cancel - - - -
- - - - -
- - )} -
- ); }; - -export default AlertRuleForm; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeSection.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeSection.tsx deleted file mode 100644 index 4e804d1d1bd..00000000000 --- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeSection.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import React, { FC, useState, useEffect } from 'react'; -import { GrafanaTheme, SelectableValue } from '@grafana/data'; -import { Cascader, FieldSet, Field, Input, InputControl, stylesFactory, Select, CascaderOption } from '@grafana/ui'; -import { config } from 'app/core/config'; -import { css } from '@emotion/css'; - -import { getAllDataSources } from '../../utils/config'; -import { fetchRulerRules } from '../../api/ruler'; -import { AlertRuleFormMethods } from './AlertRuleForm'; -import { getRulesDataSources } from '../../utils/datasource'; - -interface Props extends AlertRuleFormMethods { - setFolder: ({ namespace, group }: { namespace: string; group: string }) => void; -} - -enum ALERT_TYPE { - THRESHOLD = 'threshold', - SYSTEM = 'system', - HOST = 'host', -} - -const alertTypeOptions: SelectableValue[] = [ - { - label: 'Threshold', - value: ALERT_TYPE.THRESHOLD, - description: 'Metric alert based on a defined threshold', - }, - { - label: 'System or application', - value: ALERT_TYPE.SYSTEM, - description: 'Alert based on a system or application behavior. Based on Prometheus.', - }, -]; - -const AlertTypeSection: FC = ({ register, control, watch, setFolder, errors }) => { - const styles = getStyles(config.theme); - - const alertType = watch('type') as SelectableValue; - const datasource = watch('dataSource') as SelectableValue; - const dataSourceOptions = useDatasourceSelectOptions(alertType); - const folderOptions = useFolderSelectOptions(datasource); - - return ( -
- - - -
- - - - - - -
- - { - const [namespace, group] = value.split(' > '); - setFolder({ namespace, group }); - }} - /> - -
- ); -}; - -const useDatasourceSelectOptions = (alertType: SelectableValue) => { - const [datasourceOptions, setDataSourceOptions] = useState([]); - - useEffect(() => { - let options = [] as ReturnType; - if (alertType?.value === ALERT_TYPE.THRESHOLD) { - options = getAllDataSources().filter(({ type }) => type !== 'datasource'); - } else if (alertType?.value === ALERT_TYPE.SYSTEM) { - options = getRulesDataSources(); - } - setDataSourceOptions( - options.map(({ name, type }) => { - return { - label: name, - value: name, - description: type, - }; - }) - ); - }, [alertType?.value]); - - return datasourceOptions; -}; - -const useFolderSelectOptions = (datasource: SelectableValue) => { - const [folderOptions, setFolderOptions] = useState([]); - - useEffect(() => { - if (datasource?.value) { - fetchRulerRules(datasource?.value) - .then((namespaces) => { - const options: CascaderOption[] = Object.entries(namespaces).map(([namespace, group]) => { - return { - label: namespace, - value: namespace, - items: group.map(({ name }) => { - return { label: name, value: `${namespace} > ${name}` }; - }), - }; - }); - setFolderOptions(options); - }) - .catch((error) => { - if (error.status === 404) { - setFolderOptions([{ label: 'No folders found', value: '' }]); - } - }); - } - }, [datasource?.value]); - - return folderOptions; -}; - -const getStyles = stylesFactory((theme: GrafanaTheme) => { - return { - formInput: css` - width: 400px; - & + & { - margin-left: ${theme.spacing.sm}; - } - `, - flexRow: css` - display: flex; - flex-direction: row; - justify-content: flex-start; - `, - }; -}); - -export default AlertTypeSection; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx new file mode 100644 index 00000000000..ace99eb898b --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx @@ -0,0 +1,175 @@ +import React, { FC, useCallback, useEffect } from 'react'; +import { DataSourceInstanceSettings, GrafanaTheme, SelectableValue } from '@grafana/data'; +import { Field, Input, InputControl, Select, useStyles } from '@grafana/ui'; +import { css } from '@emotion/css'; + +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'; + +const alertTypeOptions: SelectableValue[] = [ + { + label: 'Threshold', + value: RuleFormType.threshold, + description: 'Metric alert based on a defined threshold', + }, + { + label: 'System or application', + value: RuleFormType.system, + description: 'Alert based on a system or application behavior. Based on Prometheus.', + }, +]; + +export const AlertTypeStep: FC = () => { + const styles = useStyles(getStyles); + + const { register, control, watch, errors, setValue } = useFormContext(); + + const ruleFormType = watch('type'); + const dataSourceName = watch('dataSourceName'); + + useEffect(() => {}, [ruleFormType]); + + const rulesSourcesWithRuler = useRulesSourcesWithRuler(); + + const dataSourceFilter = useCallback( + (ds: DataSourceInstanceSettings): boolean => { + if (ruleFormType === RuleFormType.threshold) { + return !!ds.meta.alerting; + } else { + // filter out only rules sources that support ruler and thus can have alerts edited + return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id); + } + }, + [ruleFormType, rulesSourcesWithRuler] + ); + + return ( + + + + +
+ + { + const value = values[0]?.value; + // when switching to system alerts, null out data source selection if it's not a rules source with ruler + if ( + value === RuleFormType.system && + dataSourceName && + !rulesSourcesWithRuler.find(({ name }) => name === dataSourceName) + ) { + setValue('dataSourceName', null); + } + return value; + }} + /> + + + >} + valueName="current" + filter={dataSourceFilter} + name="dataSourceName" + noDefault={true} + control={control} + alerting={true} + rules={{ + required: { value: true, message: 'Please select a data source' }, + }} + onChange={(ds: DataSourceInstanceSettings[]) => { + // reset location if switching data sources, as differnet rules source will have different groups and namespaces + setValue('location', undefined); + return ds[0]?.name ?? null; + }} + /> + +
+ {ruleFormType === RuleFormType.system && ( + + {dataSourceName ? ( + + ) : ( + onChange((e.target as HTMLInputElement).value)} + /> + ); + } else { + return ( +