diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx new file mode 100644 index 00000000000..8a8a745253b --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditor.test.tsx @@ -0,0 +1,320 @@ +import { Matcher, render, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { locationService, setDataSourceSrv, setBackendSrv, BackendSrv } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; +import RuleEditor from './RuleEditor'; +import { Router, Route } from 'react-router-dom'; +import React from 'react'; +import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; +import { contextSrv } from 'app/core/services/context_srv'; +import { mockDataSource, MockDataSourceSrv } from './mocks'; +import userEvent from '@testing-library/user-event'; +import { DataSourceInstanceSettings } from '@grafana/data'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { getAllDataSources } from './utils/config'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { getDefaultQueries } from './utils/rule-form'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/ruler'); +jest.mock('./utils/config'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +const mocks = { + getAllDataSources: typeAsJestMock(getAllDataSources), + + api: { + fetchRulerRulesGroup: typeAsJestMock(fetchRulerRulesGroup), + setRulerRuleGroup: typeAsJestMock(setRulerRuleGroup), + fetchRulerRulesNamespace: typeAsJestMock(fetchRulerRulesNamespace), + fetchRulerRules: typeAsJestMock(fetchRulerRules), + }, +}; + +function renderRuleEditor(identifier?: string) { + const store = configureStore(); + + locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`); + + return render( + + + + + + ); +} + +const ui = { + inputs: { + name: byLabelText('Alert name'), + alertType: byTestId('alert-type-picker'), + dataSource: byTestId('datasource-picker'), + folder: byTestId('folder-picker'), + namespace: byTestId('namespace-picker'), + group: byTestId('group-picker'), + annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`), + annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), + labelKey: (idx: number) => byTestId(`label-key-${idx}`), + labelValue: (idx: number) => byTestId(`label-value-${idx}`), + expr: byTestId('expr'), + }, + buttons: { + save: byRole('button', { name: 'Save' }), + addAnnotation: byRole('button', { name: /Add info/ }), + addLabel: byRole('button', { name: /Add label/ }), + }, +}; + +describe('RuleEditor', () => { + beforeEach(() => { + jest.resetAllMocks(); + contextSrv.isEditor = true; + }); + + it('can create a new cloud alert', async () => { + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: true } + ), + }; + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + namespace1: [ + { + name: 'group1', + rules: [], + }, + ], + namespace2: [ + { + name: 'group2', + rules: [], + }, + ], + }); + + await renderRuleEditor(); + await userEvent.type(await ui.inputs.name.find(), 'my great new rule'); + clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed alert/); + const dataSourceSelect = ui.inputs.dataSource.get(); + userEvent.click(byRole('textbox').get(dataSourceSelect)); + userEvent.click(await byText('Prom (default)').find(dataSourceSelect)); + await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); + clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); + clickSelectOption(ui.inputs.group.get(), 'group2'); + + await userEvent.type(ui.inputs.expr.get(), 'up == 1'); + + await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary'); + await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); + + userEvent.click(ui.buttons.addLabel.get()); + + await userEvent.type(ui.inputs.labelKey(0).get(), 'severity'); + await userEvent.type(ui.inputs.labelValue(0).get(), 'warn'); + await userEvent.type(ui.inputs.labelKey(1).get(), 'team'); + await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team'); + + // save and check what was sent to backend + userEvent.click(ui.buttons.save.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith('Prom', 'namespace2', { + name: 'group2', + rules: [ + { + alert: 'my great new rule', + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + expr: 'up == 1', + for: '1m', + }, + ], + }); + }); + + it('can create new grafana managed alert', async () => { + const searchFolderMock = jest.fn().mockResolvedValue([ + { + title: 'Folder A', + id: 1, + }, + { + title: 'Folder B', + id: 2, + }, + ] as DashboardSearchHit[]); + + const dataSources = { + default: mockDataSource({ + type: 'prometheus', + name: 'Prom', + isDefault: true, + }), + }; + + const backendSrv = ({ + search: searchFolderMock, + } as any) as BackendSrv; + setBackendSrv(backendSrv); + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + + // fill out the form + await renderRuleEditor(); + userEvent.type(await ui.inputs.name.find(), 'my great new rule'); + clickSelectOption(ui.inputs.alertType.get(), /Classic Grafana alerts based on thresholds/); + const folderInput = await ui.inputs.folder.find(); + await waitFor(() => expect(searchFolderMock).toHaveBeenCalled()); + clickSelectOption(folderInput, 'Folder A'); + + await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary'); + await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); + + userEvent.click(ui.buttons.addLabel.get()); + + await userEvent.type(ui.inputs.labelKey(0).get(), 'severity'); + await userEvent.type(ui.inputs.labelValue(0).get(), 'warn'); + await userEvent.type(ui.inputs.labelKey(1).get(), 'team'); + await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team'); + + // save and check what was sent to backend + userEvent.click(ui.buttons.save.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, 'Folder A', { + interval: '1m', + name: 'my great new rule', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + condition: 'B', + data: getDefaultQueries(), + exec_err_state: 'Alerting', + no_data_state: 'NoData', + title: 'my great new rule', + }, + }, + ], + }); + }); + + it('for cloud alerts, should only allow to select editable rules sources', async () => { + const dataSources: Record> = { + // can edit rules + loki: mockDataSource( + { + type: DataSourceType.Loki, + name: 'loki with ruler', + }, + { alerting: true } + ), + // can edit rules + prom: mockDataSource( + { + type: DataSourceType.Prometheus, + name: 'cortex with ruler', + }, + { alerting: true } + ), + // cannot edit rules + loki_local_rule_store: mockDataSource( + { + type: DataSourceType.Loki, + name: 'loki with local rule store', + }, + { alerting: true } + ), + // cannot edit rules + prom_no_ruler_api: mockDataSource( + { + type: DataSourceType.Loki, + name: 'cortex without ruler api', + }, + { alerting: true } + ), + // not a supported datasource type + splunk: mockDataSource( + { + type: 'splunk', + name: 'splunk', + }, + { alerting: true } + ), + }; + + mocks.api.fetchRulerRulesGroup.mockImplementation(async (dataSourceName: string) => { + if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { + return null; + } + if (dataSourceName === 'loki with local rule store') { + throw { + status: 400, + data: { + message: 'GetRuleGroup unsupported in rule local store', + }, + }; + } + if (dataSourceName === 'cortex without ruler api') { + throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?'); + } + return null; + }); + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + + // render rule editor, select cortex/loki managed alerts + await renderRuleEditor(); + await ui.inputs.name.find(); + clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed alert/); + + // wait for ui theck each datasource if it supports rule editing + await waitFor(() => expect(mocks.api.fetchRulerRulesGroup).toHaveBeenCalledTimes(4)); + + // check that only rules sources that have ruler available are there + const dataSourceSelect = ui.inputs.dataSource.get(); + userEvent.click(byRole('textbox').get(dataSourceSelect)); + expect(await byText('loki with ruler').find(dataSourceSelect)).toBeInTheDocument(); + expect(byText('cortex with ruler').query(dataSourceSelect)).toBeInTheDocument(); + expect(byText('loki with local rule store').query(dataSourceSelect)).not.toBeInTheDocument(); + expect(byText('prom without ruler api').query(dataSourceSelect)).not.toBeInTheDocument(); + expect(byText('splunk').query(dataSourceSelect)).not.toBeInTheDocument(); + }); +}); + +const clickSelectOption = (selectElement: HTMLElement, optionText: Matcher): void => { + userEvent.click(byRole('textbox').get(selectElement)); + userEvent.click(byText(optionText).get(selectElement)); +}; diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index f4c2e445cac..d22917be733 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -22,7 +22,7 @@ const ExistingRuleEditor: FC = ({ identifier }) => { useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule); const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule); const dispatch = useDispatch(); - const { isEditable } = useIsRuleEditable(result?.rule); + const { isEditable } = useIsRuleEditable(ruleId.ruleIdentifierToRuleSourceName(identifier), result?.rule); useEffect(() => { if (!dispatched) { diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts index 407dff5cdf9..fb16081bb08 100644 --- a/public/app/features/alerting/unified/api/prometheus.ts +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -1,7 +1,7 @@ import { getBackendSrv } from '@grafana/runtime'; import { RuleNamespace } from 'app/types/unified-alerting'; import { PromRulesResponse } from 'app/types/unified-alerting-dto'; -import { getAllRulesSourceNames, getDatasourceAPIId } from '../utils/datasource'; +import { getDatasourceAPIId } from '../utils/datasource'; export async function fetchRules(dataSourceName: string): Promise { const response = await getBackendSrv() @@ -10,12 +10,18 @@ export async function fetchRules(dataSourceName: string): Promise { + if ('status' in e && e.status === 404) { + throw new Error('404 from rule state endpoint. Perhaps ruler API is not enabled?'); + } + throw e; + }); const nsMap: { [key: string]: RuleNamespace } = {}; response.data.data.groups.forEach((group) => { group.rules.forEach((rule) => { - rule.query = rule.query || ''; // @TODO temp fix, backend response ism issing query. remove once it's there + rule.query = rule.query || ''; }); if (!nsMap[group.file]) { nsMap[group.file] = { @@ -30,17 +36,3 @@ export async function fetchRules(dataSourceName: string): Promise { - const namespaces = [] as Array>; - getAllRulesSourceNames().forEach(async (name) => { - namespaces.push( - fetchRules(name).catch((e) => { - return []; - // TODO add error comms - }) - ); - }); - const promises = await Promise.all(namespaces); - return promises.flat(); -} diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index 23dca2687ea..2508dcb5601 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -75,8 +75,11 @@ async function rulerGetRequest(url: string, empty: T): Promise { .toPromise(); return response.data; } catch (e) { - if (e?.status === 404 || e?.data?.message?.includes('group does not exist')) { - return empty; + if (e?.status === 404) { + if (e?.data?.message?.includes('group does not exist') || e?.data?.message?.includes('no rule groups found')) { + return empty; + } + throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?'); } else if ( e?.status === 500 && e?.data?.message?.includes('unexpected content type from upstream. expected YAML, got text/html') diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx index 231827715e3..29d91ee093b 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx @@ -1,32 +1,14 @@ -import React, { FC, useCallback, useEffect } from 'react'; +import React, { FC, useMemo } from 'react'; import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Field, Input, InputControl, Select, useStyles2 } 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 } from '@grafana/runtime'; -import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler'; import { RuleFolderPicker } from './RuleFolderPicker'; import { GroupAndNamespaceFields } from './GroupAndNamespaceFields'; import { contextSrv } from 'app/core/services/context_srv'; - -const alertTypeOptions: SelectableValue[] = [ - { - label: 'Grafana managed alert', - value: RuleFormType.grafana, - description: 'Classic Grafana alerts based on thresholds.', - }, -]; - -if (contextSrv.isEditor) { - alertTypeOptions.push({ - label: 'Cortex/Loki managed alert', - value: RuleFormType.cloud, - description: 'Alert based on a system or application behavior. Based on Prometheus.', - }); -} +import { CloudRulesSourcePicker } from './CloudRulesSourcePicker'; interface Props { editingExistingRule: boolean; @@ -46,21 +28,25 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { const ruleFormType = watch('type'); const dataSourceName = watch('dataSourceName'); - useEffect(() => {}, [ruleFormType]); + const alertTypeOptions = useMemo((): SelectableValue[] => { + const result = [ + { + label: 'Grafana managed alert', + value: RuleFormType.grafana, + description: 'Classic Grafana alerts based on thresholds.', + }, + ]; - const rulesSourcesWithRuler = useRulesSourcesWithRuler(); + if (contextSrv.isEditor) { + result.push({ + label: 'Cortex/Loki managed alert', + value: RuleFormType.cloud, + description: 'Alert based on a system or application behavior. Based on Prometheus.', + }); + } - const dataSourceFilter = useCallback( - (ds: DataSourceInstanceSettings): boolean => { - if (ruleFormType === RuleFormType.grafana) { - 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 result; + }, []); return ( @@ -71,6 +57,7 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { invalid={!!errors.name?.message} > @@ -82,25 +69,11 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { className={styles.formInput} error={errors.type?.message} invalid={!!errors.type?.message} + data-testid="alert-type-picker" > ( - onChange(v?.value)} /> )} name="type" control={control} @@ -115,15 +88,12 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { label="Select data source" error={errors.dataSourceName?.message} invalid={!!errors.dataSourceName?.message} + data-testid="datasource-picker" > ( - ( + { // reset location if switching data sources, as different rules source will have different groups and namespaces setValue('location', undefined); @@ -149,6 +119,7 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { className={styles.formInput} error={errors.folder?.message} invalid={!!errors.folder?.message} + data-testid="folder-picker" > ( diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx index 4841452dccf..31376aa99bd 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx @@ -37,6 +37,7 @@ const AnnotationsField: FC = () => { className={styles.field} invalid={!!errors.annotations?.[index]?.key?.message} error={errors.annotations?.[index]?.key?.message} + data-testid={`annotation-key-${index}`} > { error={errors.annotations?.[index]?.value?.message} > void; + value: string | null; + onBlur?: () => void; + name?: string; +} + +export function CloudRulesSourcePicker({ value, ...props }: Props): JSX.Element { + const rulesSourcesWithRuler = useRulesSourcesWithRuler(); + + const dataSourceFilter = useCallback( + (ds: DataSourceInstanceSettings): boolean => { + return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id); + }, + [rulesSourcesWithRuler] + ); + + return ; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx index 7d7906a1c7b..95b4938209c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx @@ -6,13 +6,13 @@ import { useAsync } from 'react-use'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; -interface Props { +export interface ExpressionEditorProps { value?: string; onChange: (value: string) => void; dataSourceName: string; // will be a prometheus or loki datasource } -export const ExpressionEditor: FC = ({ value, onChange, dataSourceName }) => { +export const ExpressionEditor: FC = ({ value, onChange, dataSourceName }) => { const { mapToValue, mapToQuery } = useQueryMappers(dataSourceName); const [query, setQuery] = useState(mapToQuery({ refId: 'A', hide: false }, value)); const { error, loading, value: dataSource } = useAsync(() => { diff --git a/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx b/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx index 0932f5596b1..64aead01bf9 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx @@ -49,7 +49,12 @@ export const GroupAndNamespaceFields: FC = ({ dataSourceName }) => { return (
- + ( = ({ dataSourceName }) => { }} /> - + ( diff --git a/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx index f2fde42a716..d6e764bc1c3 100644 --- a/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx @@ -41,6 +41,7 @@ const LabelsField: FC = ({ className }) => { required: { value: !!labels[index]?.value, message: 'Required.' }, })} placeholder="key" + data-testid={`label-key-${index}`} defaultValue={field.key} /> @@ -55,6 +56,7 @@ const LabelsField: FC = ({ className }) => { required: { value: !!labels[index]?.key, message: 'Required.' }, })} placeholder="value" + data-testid={`label-value-${index}`} defaultValue={field.value} /> diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index c8a8533c999..724b0fbc1d5 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -28,7 +28,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { const leftButtons: JSX.Element[] = []; const rightButtons: JSX.Element[] = []; - const { isEditable } = useIsRuleEditable(rulerRule); + const { isEditable } = useIsRuleEditable(getRulesSourceName(rulesSource), rulerRule); const returnTo = location.pathname + location.search; const isViewMode = inViewMode(location.pathname); diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts index 3e3c93a338e..0cb8e4d6d0c 100644 --- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts +++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts @@ -2,17 +2,29 @@ import { contextSrv } from 'app/core/services/context_srv'; import { isGrafanaRulerRule } from '../utils/rules'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { useFolder } from './useFolder'; +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; +import { useDispatch } from 'react-redux'; +import { useEffect } from 'react'; +import { checkIfLotexSupportsEditingRulesAction } from '../state/actions'; interface ResultBag { isEditable?: boolean; loading: boolean; } -export function useIsRuleEditable(rule?: RulerRuleDTO): ResultBag { +export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag { + const checkEditingRequests = useUnifiedAlertingSelector((state) => state.lotexSupportsRuleEditing); + const dispatch = useDispatch(); const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined; const { folder, loading } = useFolder(folderUID); + useEffect(() => { + if (checkEditingRequests[rulesSourceName] === undefined) { + dispatch(checkIfLotexSupportsEditingRulesAction(rulesSourceName)); + } + }, [rulesSourceName, checkEditingRequests, dispatch]); + if (!rule) { return { isEditable: false, loading: false }; } @@ -30,9 +42,9 @@ export function useIsRuleEditable(rule?: RulerRuleDTO): ResultBag { }; } - // prom rules are only editable by users with Editor role + // prom rules are only editable by users with Editor role and only if rules source supports editing return { - isEditable: contextSrv.isEditor, - loading: false, + isEditable: contextSrv.isEditor && !!checkEditingRequests[rulesSourceName]?.result, + loading: !!checkEditingRequests[rulesSourceName]?.loading, }; } diff --git a/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts b/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts index 70a4bc2a5a1..b2aa8275c91 100644 --- a/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts +++ b/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts @@ -1,24 +1,22 @@ import { DataSourceInstanceSettings } from '@grafana/data'; import { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { fetchRulerRulesIfNotFetchedYet } from '../state/actions'; -import { getAllDataSources } from '../utils/config'; -import { DataSourceType, getRulesDataSources } from '../utils/datasource'; +import { checkIfLotexSupportsEditingRulesAction } from '../state/actions'; +import { getRulesDataSources } from '../utils/datasource'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; export function useRulesSourcesWithRuler(): DataSourceInstanceSettings[] { - const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + const checkEditingRequests = useUnifiedAlertingSelector((state) => state.lotexSupportsRuleEditing); const dispatch = useDispatch(); // try fetching rules for each prometheus to see if it has ruler useEffect(() => { - getAllDataSources() - .filter((ds) => ds.type === DataSourceType.Prometheus) - .forEach((ds) => dispatch(fetchRulerRulesIfNotFetchedYet(ds.name))); - }, [dispatch]); + getRulesDataSources() + .filter((ds) => checkEditingRequests[ds.name] === undefined) + .forEach((ds) => dispatch(checkIfLotexSupportsEditingRulesAction(ds.name))); + }, [dispatch, checkEditingRequests]); - return useMemo( - () => getRulesDataSources().filter((ds) => ds.type === DataSourceType.Loki || !!rulerRequests[ds.name]?.result), - [rulerRequests] - ); + return useMemo(() => getRulesDataSources().filter((ds) => checkEditingRequests[ds.name]?.result), [ + checkEditingRequests, + ]); } diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 1c586942e4f..3cb8994ceff 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -14,7 +14,10 @@ import { let nextDataSourceId = 1; -export const mockDataSource = (partial: Partial = {}): DataSourceInstanceSettings => { +export const mockDataSource = ( + partial: Partial = {}, + meta: Partial = {} +): DataSourceInstanceSettings => { const id = partial.id ?? nextDataSourceId++; return { @@ -30,6 +33,7 @@ export const mockDataSource = (partial: Partial = {} large: 'https://prometheus.io/assets/prometheus_logo_grey.svg', }, }, + ...meta, } as any) as DataSourcePluginMeta, ...partial, }; @@ -140,6 +144,7 @@ export const mockAlertGroup = (partial: Partial = {}): Alertm }; export class MockDataSourceSrv implements DataSourceSrv { + datasources: Record = {}; // @ts-ignore private settingsMapByName: Record = {}; private settingsMapByUid: Record = {}; @@ -149,8 +154,10 @@ export class MockDataSourceSrv implements DataSourceSrv { getVariables: () => [], replace: (name: any) => name, }; + defaultName = ''; constructor(datasources: Record) { + this.datasources = {}; this.settingsMapByName = Object.values(datasources).reduce>( (acc, ds) => { acc[ds.name] = ds; @@ -161,11 +168,15 @@ export class MockDataSourceSrv implements DataSourceSrv { for (const dsSettings of Object.values(this.settingsMapByName)) { this.settingsMapByUid[dsSettings.uid] = dsSettings; this.settingsMapById[dsSettings.id] = dsSettings; + if (dsSettings.isDefault) { + this.defaultName = dsSettings.name; + } } } get(name?: string | null, scopedVars?: ScopedVars): Promise { - return Promise.reject(new Error('not implemented')); + return DatasourceSrv.prototype.get.call(this, name, scopedVars); + //return Promise.reject(new Error('not implemented')); } /** @@ -184,6 +195,10 @@ export class MockDataSourceSrv implements DataSourceSrv { (({ meta: { info: { logos: {} } } } as unknown) as DataSourceInstanceSettings) ); } + + async loadDatasource(name: string): Promise> { + return DatasourceSrv.prototype.loadDatasource.call(this, name); + } } export const mockGrafanaReceiver = ( diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index b2b3f6a1aa5..091b36708c2 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -37,7 +37,7 @@ import { import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource'; import { makeAMLink } from '../utils/misc'; -import { withAppEvents, withSerializedError } from '../utils/redux'; +import { isFetchError, withAppEvents, withSerializedError } from '../utils/redux'; import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form'; import { isCloudRuleIdentifier, @@ -543,3 +543,29 @@ export const fetchAlertGroupsAction = createAsyncThunk( return withSerializedError(fetchAlertGroups(alertManagerSourceName)); } ); + +export const checkIfLotexSupportsEditingRulesAction = createAsyncThunk( + 'unifiedalerting/checkIfLotexRuleEditingSupported', + async (rulesSourceName: string): Promise => + withAppEvents( + (async () => { + try { + await fetchRulerRulesGroup(rulesSourceName, 'test', 'test'); + return true; + } catch (e) { + if ( + (isFetchError(e) && + (e.data.message?.includes('GetRuleGroup unsupported in rule local store') || // "local" rule storage + e.data.message?.includes('page not found'))) || // ruler api disabled + e.message?.includes('404 from rules config endpoint') // ruler api disabled + ) { + return false; + } + throw e; + } + })(), + { + errorMessage: `Failed to determine if "${rulesSourceName}" allows editing rules`, + } + ) +); diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 26e0a51537a..8197289461f 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -13,6 +13,7 @@ import { createOrUpdateSilenceAction, fetchFolderAction, fetchAlertGroupsAction, + checkIfLotexSupportsEditingRulesAction, } from './actions'; export const reducer = combineReducers({ @@ -40,6 +41,11 @@ export const reducer = combineReducers({ fetchAlertGroupsAction, (alertManagerSourceName) => alertManagerSourceName ).reducer, + lotexSupportsRuleEditing: createAsyncMapSlice( + 'lotexSupportsRuleEditing', + checkIfLotexSupportsEditingRulesAction, + (source) => source + ).reducer, }); export type UnifiedAlertingState = ReturnType; diff --git a/public/app/features/alerting/unified/utils/redux.ts b/public/app/features/alerting/unified/utils/redux.ts index 067f6ffa420..f6e71cfa651 100644 --- a/public/app/features/alerting/unified/utils/redux.ts +++ b/public/app/features/alerting/unified/utils/redux.ts @@ -126,7 +126,7 @@ export function withAppEvents( }); } -function isFetchError(e: unknown): e is FetchError { +export function isFetchError(e: unknown): e is FetchError { return typeof e === 'object' && e !== null && 'status' in e && 'data' in e; } diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index 4081c931afb..6b8d34f8c78 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -1,5 +1,6 @@ import { CombinedRule, Rule, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting'; import { Annotations, Labels, RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { isAlertingRule, isAlertingRulerRule, @@ -211,3 +212,7 @@ function hashRule(rule: Rule): number { function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string { return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0]))); } + +export function ruleIdentifierToRuleSourceName(identifier: RuleIdentifier): string { + return isGrafanaRuleIdentifier(identifier) ? GRAFANA_RULES_SOURCE_NAME : identifier.ruleSourceName; +}