diff --git a/pkg/services/ngalert/api/lotex_ruler.go b/pkg/services/ngalert/api/lotex_ruler.go index 9cee9799cbb..8c71f8dd962 100644 --- a/pkg/services/ngalert/api/lotex_ruler.go +++ b/pkg/services/ngalert/api/lotex_ruler.go @@ -16,9 +16,32 @@ import ( "github.com/grafana/grafana/pkg/models" ) +const ( + Prometheus = "prometheus" + Cortex = "cortex" + Mimir = "mimir" +) + +const ( + PrometheusDatasourceType = "prometheus" + LokiDatasourceType = "loki" + + mimirPrefix = "/config/v1/rules" + prometheusPrefix = "/rules" + lokiPrefix = "/api/prom/rules" + + subtypeQuery = "subtype" +) + var dsTypeToRulerPrefix = map[string]string{ - "prometheus": "/rules", - "loki": "/api/prom/rules", + PrometheusDatasourceType: prometheusPrefix, + LokiDatasourceType: lokiPrefix, +} + +var subtypeToPrefix = map[string]string{ + Prometheus: prometheusPrefix, + Cortex: prometheusPrefix, + Mimir: mimirPrefix, } type LotexRuler struct { @@ -171,7 +194,22 @@ func (r *LotexRuler) validateAndGetPrefix(ctx *models.ReqContext) (string, error if !ok { return "", fmt.Errorf("unexpected datasource type. expecting loki or prometheus") } - return prefix, nil + + // If the datasource is Loki, there's nothing else for us to do - it doesn't have subtypes. + if ds.Type == LokiDatasourceType { + return prefix, nil + } + + // A Prometheus datasource, can have many subtypes: Cortex, Mimir and vanilla Prometheus. + // Based on these subtypes, we want to use a different proxying path. + subtype := ctx.Query(subtypeQuery) + subTypePrefix, ok := subtypeToPrefix[subtype] + if !ok { + r.log.Debug("unable to determine prometheus datasource subtype, using default prefix", "subtype", subtype) + return prefix, nil + } + + return subTypePrefix, nil } func withPath(u url.URL, newPath string) *url.URL { diff --git a/pkg/services/ngalert/api/lotex_ruler_test.go b/pkg/services/ngalert/api/lotex_ruler_test.go new file mode 100644 index 00000000000..cc7d1d27c70 --- /dev/null +++ b/pkg/services/ngalert/api/lotex_ruler_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/datasourceproxy" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/web" +) + +func TestLotexRuler_ValidateAndGetPrefix(t *testing.T) { + tc := []struct { + name string + namedParams map[string]string + urlParams string + datasourceCache datasources.CacheService + expected string + err error + }{ + { + name: "with an invalid recipient", + namedParams: map[string]string{":Recipient": "AAABBB"}, + err: errors.New("recipient is invalid"), + }, + { + name: "with an error while trying to fetch the datasource", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{err: models.ErrDataSourceNotFound}, + err: errors.New("data source not found"), + }, + { + name: "with an empty datasource URL", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{}}, + err: errors.New("URL for this data source is empty"), + }, + { + name: "with an unsupported datasource type", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com"}}, + err: errors.New("unexpected datasource type. expecting loki or prometheus"), + }, + { + name: "with a Loki datasource", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: LokiDatasourceType}}, + expected: "/api/prom/rules", + }, + { + name: "with a Prometheus datasource", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/rules", + }, + { + name: "with a Prometheus datasource and subtype of Cortex", + namedParams: map[string]string{":Recipient": "164"}, + urlParams: "?subtype=cortex", + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/rules", + }, + { + name: "with a Prometheus datasource and subtype of Mimir", + namedParams: map[string]string{":Recipient": "164"}, + urlParams: "?subtype=mimir", + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/config/v1/rules", + }, + { + name: "with a Prometheus datasource and subtype of Prometheus", + namedParams: map[string]string{":Recipient": "164"}, + urlParams: "?subtype=prometheus", + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/rules", + }, + { + name: "with a Prometheus datasource and no subtype", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/rules", + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + // Setup Proxy. + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: tt.datasourceCache}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger()} + + // Setup request context. + httpReq, err := http.NewRequest(http.MethodGet, "http://grafanacloud.com"+tt.urlParams, nil) + require.NoError(t, err) + ctx := &models.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, tt.namedParams)}} + + prefix, err := ruler.validateAndGetPrefix(ctx) + require.Equal(t, tt.expected, prefix) + if tt.err != nil { + require.EqualError(t, err, tt.err.Error()) + } + }) + } +} + +type fakeCacheService struct { + datasource *models.DataSource + err error +} + +func (f fakeCacheService) GetDatasource(_ context.Context, datasourceID int64, _ *models.SignedInUser, _ bool) (*models.DataSource, error) { + if f.err != nil { + return nil, f.err + } + + return f.datasource, nil +} + +func (f fakeCacheService) GetDatasourceByUID(ctx context.Context, datasourceUID string, user *models.SignedInUser, skipCache bool) (*models.DataSource, error) { + if f.err != nil { + return nil, f.err + } + + return f.datasource, nil +} diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index 1e595e199f4..659d9076f79 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -333,10 +333,13 @@ describe('PanelAlertTabContent', () => { ], }); - expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { - dashboardUID: dashboard.uid, - panelId: panel.id, - }); + expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + { + dashboardUID: dashboard.uid, + panelId: panel.id, + } + ); expect(mocks.api.fetchRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { dashboardUID: dashboard.uid, panelId: panel.id, diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx index c3efcb61d44..d257846c641 100644 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ b/public/app/features/alerting/unified/RuleEditor.test.tsx @@ -17,8 +17,9 @@ 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'; -import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; +import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; +import { fetchBuildInfo } from './api/buildInfo'; jest.mock('./components/rule-editor/ExpressionEditor', () => ({ // eslint-disable-next-line react/display-name @@ -27,6 +28,7 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ), })); +jest.mock('./api/buildInfo'); jest.mock('./api/ruler'); jest.mock('./utils/config'); jest.mock('../../../../app/features/manage-dashboards/state/actions'); @@ -42,6 +44,7 @@ const mocks = { getAllDataSources: jest.mocked(getAllDataSources), searchFolders: jest.mocked(searchFolders), api: { + fetchBuildInfo: jest.mocked(fetchBuildInfo), fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), setRulerRuleGroup: jest.mocked(setRulerRuleGroup), fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), @@ -130,10 +133,17 @@ describe('RuleEditor', () => { }); mocks.searchFolders.mockResolvedValue([]); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + await renderRuleEditor(); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchRulerRulesGroup).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); userEvent.type(await ui.inputs.name.find(), 'my great new rule'); userEvent.click(await ui.buttons.lotexAlert.get()); const dataSourceSelect = ui.inputs.dataSource.get(); @@ -159,18 +169,22 @@ describe('RuleEditor', () => { // 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', - }, - ], - }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: 'Prom', apiVersion: 'legacy' }, + '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 () => { @@ -218,9 +232,17 @@ describe('RuleEditor', () => { }, ] as DashboardSearchHit[]); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: false, + }, + }); + // fill out the form await renderRuleEditor(); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); userEvent.type(await ui.inputs.name.find(), 'my great new rule'); @@ -241,24 +263,28 @@ describe('RuleEditor', () => { // 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', + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + '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('can create a new cloud recording rule', async () => { @@ -297,9 +323,16 @@ describe('RuleEditor', () => { }); mocks.searchFolders.mockResolvedValue([]); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + await renderRuleEditor(); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); userEvent.click(await ui.buttons.lotexRecordingRule.get()); @@ -337,16 +370,20 @@ describe('RuleEditor', () => { // 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: [ - { - record: 'my:great:new:recording:rule', - labels: { team: 'the a-team' }, - expr: 'up == 1', - }, - ], - }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: 'Prom', apiVersion: 'legacy' }, + 'namespace2', + { + name: 'group2', + rules: [ + { + record: 'my:great:new:recording:rule', + labels: { team: 'the a-team' }, + expr: 'up == 1', + }, + ], + } + ); }); it('can edit grafana managed rule', async () => { @@ -373,6 +410,7 @@ describe('RuleEditor', () => { } as any as BackendSrv; setBackendSrv(backendSrv); setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.api.setRulerRuleGroup.mockResolvedValue(); mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); @@ -405,6 +443,8 @@ describe('RuleEditor', () => { await renderRuleEditor(uid); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); + await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); // check that it's filled in const nameInput = await ui.inputs.name.find(); @@ -426,25 +466,29 @@ describe('RuleEditor', () => { 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', custom: 'value' }, - labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, - for: '5m', - grafana_alert: { - uid, - condition: 'B', - data: getDefaultQueries(), - exec_err_state: 'Alerting', - no_data_state: 'NoData', - title: 'my great new rule', + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + 'Folder A', + { + interval: '1m', + name: 'my great new rule', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, + labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, + for: '5m', + grafana_alert: { + uid, + 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 () => { @@ -501,7 +545,45 @@ describe('RuleEditor', () => { ), }; - mocks.api.fetchRulerRulesGroup.mockImplementation(async (dataSourceName: string) => { + mocks.api.fetchBuildInfo.mockImplementation(async (dataSourceName) => { + if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { + return { + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + alertManagerConfigApi: false, + federatedRules: false, + querySharding: false, + }, + }; + } + if (dataSourceName === 'loki with local rule store') { + return { + application: PromApplication.Cortex, + features: { + rulerApiEnabled: false, + alertManagerConfigApi: false, + federatedRules: false, + querySharding: false, + }, + }; + } + if (dataSourceName === 'cortex without ruler api') { + return { + application: PromApplication.Cortex, + features: { + rulerApiEnabled: false, + alertManagerConfigApi: false, + federatedRules: false, + querySharding: false, + }, + }; + } + + throw new Error(`${dataSourceName} not handled`); + }); + + mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => { if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { return null; } @@ -525,9 +607,8 @@ describe('RuleEditor', () => { // render rule editor, select mimir/loki managed alerts await renderRuleEditor(); + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - // wait for ui theck each datasource if it supports rule editing - await waitFor(() => expect(mocks.api.fetchRulerRulesGroup).toHaveBeenCalledTimes(4)); await ui.inputs.name.find(); userEvent.click(await ui.buttons.lotexAlert.get()); diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index d22917be733..143c1f597ab 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -2,16 +2,17 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, LinkButton, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; -import { contextSrv } from 'app/core/services/context_srv'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { contextSrv } from 'app/core/services/context_srv'; import { RuleIdentifier } from 'app/types/unified-alerting'; import React, { FC, useEffect } from 'react'; import { useDispatch } from 'react-redux'; +import { useAsync } from 'react-use'; import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; import { useIsRuleEditable } from './hooks/useIsRuleEditable'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchEditableRuleAction } from './state/actions'; +import { fetchAllPromBuildInfoAction, fetchEditableRuleAction } from './state/actions'; import * as ruleId from './utils/rule-id'; interface ExistingRuleEditorProps { @@ -58,15 +59,30 @@ const ExistingRuleEditor: FC = ({ identifier }) => { type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>; const RuleEditor: FC = ({ match }) => { + const dispatch = useDispatch(); const { id } = match.params; const identifier = ruleId.tryParse(id, true); + const { loading } = useAsync(async () => { + await dispatch(fetchAllPromBuildInfoAction()); + }, [dispatch]); + + if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) { + return Sorry! You are not allowed to create rules.; + } + + if (loading) { + return ( + + + + ); + } + if (identifier) { return ; } - if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) { - return Sorry! You are not allowed to create rules.; - } + return ; }; diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index d7f24e8712f..1e55be07468 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -6,6 +6,7 @@ import RuleList from './RuleList'; import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; import { getAllDataSources } from './utils/config'; import { fetchRules } from './api/prometheus'; +import { fetchBuildInfo } from './api/buildInfo'; import { fetchRulerRules, deleteRulerRulesGroup, deleteNamespace, setRulerRuleGroup } from './api/ruler'; import { mockDataSource, @@ -20,11 +21,12 @@ import { } from './mocks'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { SerializedError } from '@reduxjs/toolkit'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto'; import userEvent from '@testing-library/user-event'; import { locationService, setDataSourceSrv } from '@grafana/runtime'; import { Router } from 'react-router-dom'; +jest.mock('./api/buildInfo'); jest.mock('./api/prometheus'); jest.mock('./api/ruler'); jest.mock('./utils/config'); @@ -41,6 +43,7 @@ const mocks = { getAllDataSourcesMock: jest.mocked(getAllDataSources), api: { + fetchBuildInfo: jest.mocked(fetchBuildInfo), fetchRules: jest.mocked(fetchRules), fetchRulerRules: jest.mocked(fetchRulerRules), deleteGroup: jest.mocked(deleteRulerRulesGroup), @@ -115,6 +118,13 @@ describe('RuleList', () => { setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: true, + }, + }); + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === dataSources.prom.name) { return Promise.resolve([ @@ -198,7 +208,15 @@ describe('RuleList', () => { it('expand rule group, rule and alert details', async () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); + setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { return Promise.resolve([]); @@ -333,6 +351,14 @@ describe('RuleList', () => { it('filters rules and alerts by labels', async () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); + + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + mocks.api.fetchRulerRules.mockResolvedValue({}); mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { @@ -470,11 +496,19 @@ describe('RuleList', () => { it(name, async () => { mocks.getAllDataSourcesMock.mockReturnValue(Object.values(testDatasources)); setDataSourceSrv(new MockDataSourceSrv(testDatasources)); + + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + mocks.api.fetchRules.mockImplementation((sourceName) => Promise.resolve(sourceName === testDatasources.prom.name ? somePromRules() : []) ); - mocks.api.fetchRulerRules.mockImplementation((sourceName) => - Promise.resolve(sourceName === testDatasources.prom.name ? someRulerRules : {}) + mocks.api.fetchRulerRules.mockImplementation(({ dataSourceName }) => + Promise.resolve(dataSourceName === testDatasources.prom.name ? someRulerRules : {}) ); mocks.api.setRulerRuleGroup.mockResolvedValue(); mocks.api.deleteNamespace.mockResolvedValue(); @@ -514,18 +548,26 @@ describe('RuleList', () => { expect(mocks.api.deleteNamespace).toHaveBeenCalledTimes(1); expect(mocks.api.deleteGroup).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'super namespace', { - ...someRulerRules['namespace1'][0], - name: 'super group', - interval: '5m', - }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( + 1, + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'super namespace', + { + ...someRulerRules['namespace1'][0], + name: 'super group', + interval: '5m', + } + ); expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( 2, - testDatasources.prom.name, + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, 'super namespace', someRulerRules['namespace1'][1] ); - expect(mocks.api.deleteNamespace).toHaveBeenLastCalledWith('Prometheus', 'namespace1'); + expect(mocks.api.deleteNamespace).toHaveBeenLastCalledWith( + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'namespace1' + ); }); testCase('rename just the lotex group', async () => { @@ -543,12 +585,21 @@ describe('RuleList', () => { expect(mocks.api.deleteGroup).toHaveBeenCalledTimes(1); expect(mocks.api.deleteNamespace).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'namespace1', { - ...someRulerRules['namespace1'][0], - name: 'super group', - interval: '5m', - }); - expect(mocks.api.deleteGroup).toHaveBeenLastCalledWith('Prometheus', 'namespace1', 'group1'); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( + 1, + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'namespace1', + { + ...someRulerRules['namespace1'][0], + name: 'super group', + interval: '5m', + } + ); + expect(mocks.api.deleteGroup).toHaveBeenLastCalledWith( + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'namespace1', + 'group1' + ); }); testCase('edit lotex group eval interval, no renaming', async () => { @@ -564,10 +615,15 @@ describe('RuleList', () => { expect(mocks.api.deleteGroup).not.toHaveBeenCalled(); expect(mocks.api.deleteNamespace).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'namespace1', { - ...someRulerRules['namespace1'][0], - interval: '5m', - }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( + 1, + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'namespace1', + { + ...someRulerRules['namespace1'][0], + interval: '5m', + } + ); }); }); }); diff --git a/public/app/features/alerting/unified/api/buildInfo.test.ts b/public/app/features/alerting/unified/api/buildInfo.test.ts new file mode 100644 index 00000000000..1564d7a19f6 --- /dev/null +++ b/public/app/features/alerting/unified/api/buildInfo.test.ts @@ -0,0 +1,123 @@ +import { PromApplication } from 'app/types/unified-alerting-dto'; +import { of, throwError } from 'rxjs'; +import { fetchDataSourceBuildInfo } from './buildInfo'; +import { fetchRules } from './prometheus'; +import { fetchTestRulerRulesGroup } from './ruler'; + +const fetch = jest.fn(); + +jest.mock('./prometheus'); +jest.mock('./ruler'); +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ fetch }), +})); + +const mocks = { + fetchRules: jest.mocked(fetchRules), + fetchTestRulerRulesGroup: jest.mocked(fetchTestRulerRulesGroup), +}; + +beforeEach(() => jest.clearAllMocks()); + +describe('buildInfo', () => { + describe('When buildinfo returns 200 response', () => { + it('Should return Prometheus with disabled ruler API when application and features fields are missing', async () => { + fetch.mockReturnValue( + of({ + data: { + status: 'success', + data: { + version: '2.32.1', + }, + }, + }) + ); + + const response = await fetchDataSourceBuildInfo({ url: '/datasource/proxy', name: 'Prometheus' }); + + expect(response.application).toBe(PromApplication.Prometheus); + expect(response.features.rulerApiEnabled).toBe(false); + expect(mocks.fetchRules).not.toHaveBeenCalled(); + expect(mocks.fetchTestRulerRulesGroup).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + `Should return Mimir with rulerApiEnabled set to %p according to the ruler_config_api value`, + async (rulerApiEnabled) => { + fetch.mockReturnValue( + of({ + data: { + status: 'success', + data: { + version: '2.32.1', + features: { + // 'true' and 'false' as strings is intentional + // This is the format returned from the buildinfo endpoint + ruler_config_api: rulerApiEnabled ? 'true' : 'false', + }, + }, + }, + }) + ); + + const response = await fetchDataSourceBuildInfo({ url: '/datasource/proxy', name: 'Prometheus' }); + + expect(response.application).toBe(PromApplication.Mimir); + expect(response.features.rulerApiEnabled).toBe(rulerApiEnabled); + expect(mocks.fetchRules).not.toHaveBeenCalled(); + expect(mocks.fetchTestRulerRulesGroup).not.toHaveBeenCalled(); + } + ); + }); + + describe('When buildinfo returns 404 error', () => { + it('Should return cortex with ruler API disabled when prom rules works and ruler api returns not avaiable errors', async () => { + fetch.mockReturnValue( + throwError(() => ({ + status: 404, + })) + ); + + mocks.fetchTestRulerRulesGroup.mockRejectedValue({ + status: 404, + data: { + message: 'page not found', + }, + }); + mocks.fetchRules.mockResolvedValue([]); + + const response = await fetchDataSourceBuildInfo({ url: '/datasource/proxy', name: 'Cortex' }); + + expect(response.application).toBe(PromApplication.Cortex); + expect(response.features.rulerApiEnabled).toBe(false); + + expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledTimes(1); + expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledWith('Cortex'); + + expect(mocks.fetchRules).toHaveBeenCalledTimes(1); + expect(mocks.fetchRules).toHaveBeenCalledWith('Cortex'); + }); + + it('Should return cortex with ruler API enabled when prom rules works and ruler api returns cortex error', async () => { + fetch.mockReturnValue( + throwError(() => ({ + status: 404, + })) + ); + + mocks.fetchTestRulerRulesGroup.mockResolvedValue(null); + mocks.fetchRules.mockResolvedValue([]); + + const response = await fetchDataSourceBuildInfo({ url: '/datasource/proxy', name: 'Cortex' }); + + expect(response.application).toBe(PromApplication.Cortex); + expect(response.features.rulerApiEnabled).toBe(true); + + expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledTimes(1); + expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledWith('Cortex'); + + expect(mocks.fetchRules).toHaveBeenCalledTimes(1); + expect(mocks.fetchRules).toHaveBeenCalledWith('Cortex'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/api/buildInfo.ts b/public/app/features/alerting/unified/api/buildInfo.ts new file mode 100644 index 00000000000..465908f6a95 --- /dev/null +++ b/public/app/features/alerting/unified/api/buildInfo.ts @@ -0,0 +1,130 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { PromApplication, PromBuildInfo, PromBuildInfoResponse } from 'app/types/unified-alerting-dto'; +import { lastValueFrom } from 'rxjs'; +import { isFetchError } from '../utils/alertmanager'; +import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; +import { getDataSourceByName } from '../utils/datasource'; +import { fetchRules } from './prometheus'; +import { fetchTestRulerRulesGroup } from './ruler'; + +/** + * This function will attempt to detect what type of system we are talking to; this could be + * Prometheus (vanilla) | Cortex | Mimir + * + * Cortex and Mimir allow editing rules via their API, Prometheus does not. + * Prometheus and Mimir expose a `buildinfo` endpoint, Cortex does not. + * Mimir reports which "features" are enabled or available via the buildinfo endpoint, Prometheus does not. + */ +export async function fetchDataSourceBuildInfo(dsSettings: { url: string; name: string }): Promise { + const { url, name } = dsSettings; + + const response = await lastValueFrom( + getBackendSrv().fetch({ + url: `${url}/api/v1/status/buildinfo`, + showErrorAlert: false, + showSuccessAlert: false, + }) + ).catch((e) => { + if ('status' in e && e.status === 404) { + return null; // Cortex does not support buildinfo endpoint, we return an empty response + } + + throw e; + }); + + // check if the component returns buildinfo + const hasBuildInfo = response !== null; + + // we are dealing with a Cortex datasource since the response for buildinfo came up empty + if (!hasBuildInfo) { + // check if we can fetch rules via the prometheus compatible api + const promRulesSupported = await hasPromRulesSupport(name); + if (!promRulesSupported) { + throw new Error(`Unable to fetch alert rules. Is the ${name} data source properly configured?`); + } + + // check if the ruler is enabled + const rulerSupported = await hasRulerSupport(name); + + return { + application: PromApplication.Cortex, + features: { + rulerApiEnabled: rulerSupported, + }, + }; + } + + // if no features are reported but buildinfo was return we're talking to Prometheus + const { features } = response.data.data; + if (!features) { + return { + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: false, + }, + }; + } + + // if we have both features and buildinfo reported we're talking to Mimir + return { + application: PromApplication.Mimir, + features: { + rulerApiEnabled: features?.ruler_config_api === 'true', + }, + }; +} + +/** + * Attempt to fetch buildinfo from our component + */ +export async function fetchBuildInfo(dataSourceName: string): Promise { + const dsConfig = getDataSourceByName(dataSourceName); + if (!dsConfig) { + throw new Error(`Cannot find data source configuration for ${dataSourceName}`); + } + const { url, name } = dsConfig; + if (!url) { + throw new Error(`The data souce url cannot be empty.`); + } + + return fetchDataSourceBuildInfo({ name, url }); +} + +/** + * Check if the component allows us to fetch rules + */ +async function hasPromRulesSupport(dataSourceName: string) { + try { + await fetchRules(dataSourceName); + return true; + } catch (e) { + return false; + } +} + +/** + * Attempt to check if the ruler API is enabled for Cortex, Prometheus does not support it and Mimir + * reports this via the buildInfo "features" + */ +async function hasRulerSupport(dataSourceName: string) { + try { + await fetchTestRulerRulesGroup(dataSourceName); + return true; + } catch (e) { + if (errorIndicatesMissingRulerSupport(e)) { + return false; + } + throw e; + } +} + +// there errors indicate that the ruler API might be disabled or not supported for Cortex +function errorIndicatesMissingRulerSupport(error: any) { + return ( + (isFetchError(error) && + (error.data.message?.includes('GetRuleGroup unsupported in rule local store') || // "local" rule storage + error.data.message?.includes('page not found'))) || // ruler api disabled + error.message?.includes('404 from rules config endpoint') || // ruler api disabled + error.data.message?.includes(RULER_NOT_SUPPORTED_MSG) // ruler api not supported + ); +} diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts index 5d830406bd6..db3598f04bc 100644 --- a/public/app/features/alerting/unified/api/prometheus.ts +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -10,25 +10,53 @@ export interface FetchPromRulesFilter { panelId?: number; } -export async function fetchRules(dataSourceName: string, filter?: FetchPromRulesFilter): Promise { - if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { - throw new Error('Filtering by dashboard UID is not supported for cloud rules sources.'); - } +export interface PrometheusDataSourceConfig { + dataSourceName: string; +} - const params: Record = {}; +export function prometheusUrlBuilder(dataSourceConfig: PrometheusDataSourceConfig) { + const { dataSourceName } = dataSourceConfig; + + return { + rules: (filter?: FetchPromRulesFilter) => { + const searchParams = new URLSearchParams(); + const params = prepareRulesFilterQueryParams(searchParams, filter); + + return { + url: `/api/prometheus/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, + params: params, + }; + }, + }; +} + +export function prepareRulesFilterQueryParams( + params: URLSearchParams, + filter?: FetchPromRulesFilter +): Record { if (filter?.dashboardUID) { - params['dashboard_uid'] = filter.dashboardUID; - if (filter.panelId) { - params['panel_id'] = String(filter.panelId); + params.set('dashboard_uid', filter.dashboardUID); + if (filter?.panelId) { + params.set('panel_id', String(filter.panelId)); } } + return Object.fromEntries(params); +} + +export async function fetchRules(dataSourceName: string, filter?: FetchPromRulesFilter): Promise { + if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { + throw new Error('Filtering by dashboard UID is only supported for Grafana Managed rules.'); + } + + const { url, params } = prometheusUrlBuilder({ dataSourceName }).rules(filter); + const response = await lastValueFrom( getBackendSrv().fetch({ - url: `/api/prometheus/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, + url, + params, showErrorAlert: false, showSuccessAlert: false, - params, }) ).catch((e) => { if ('status' in e && e.status === 404) { diff --git a/public/app/features/alerting/unified/api/ruler.test.ts b/public/app/features/alerting/unified/api/ruler.test.ts new file mode 100644 index 00000000000..1e1ec9ed62c --- /dev/null +++ b/public/app/features/alerting/unified/api/ruler.test.ts @@ -0,0 +1,104 @@ +import { RulerDataSourceConfig } from 'app/types/unified-alerting'; +import { getDatasourceAPIId } from '../utils/datasource'; +import { rulerUrlBuilder } from './ruler'; + +jest.mock('../utils/datasource'); + +const mocks = { + getDatasourceAPIId: jest.mocked(getDatasourceAPIId), +}; + +describe('rulerUrlBuilder', () => { + it('Should use /api/v1/rules endpoint with subtype = cortex param for legacy api version', () => { + // Arrange + const config: RulerDataSourceConfig = { + dataSourceName: 'Cortex', + apiVersion: 'legacy', + }; + + mocks.getDatasourceAPIId.mockReturnValue('ds-uid'); + + // Act + const builder = rulerUrlBuilder(config); + + const rules = builder.rules(); + const namespace = builder.namespace('test-ns'); + const group = builder.namespaceGroup('test-ns', 'test-gr'); + + // Assert + expect(rules.path).toBe('/api/ruler/ds-uid/api/v1/rules'); + expect(rules.params).toMatchObject({ subtype: 'cortex' }); + + expect(namespace.path).toBe('/api/ruler/ds-uid/api/v1/rules/test-ns'); + expect(namespace.params).toMatchObject({ subtype: 'cortex' }); + + expect(group.path).toBe('/api/ruler/ds-uid/api/v1/rules/test-ns/test-gr'); + expect(group.params).toMatchObject({ subtype: 'cortex' }); + }); + + it('Should use /api/v1/rules endpoint with subtype = mimir parameter for config api version', () => { + // Arrange + const config: RulerDataSourceConfig = { + dataSourceName: 'Cortex v2', + apiVersion: 'config', + }; + + mocks.getDatasourceAPIId.mockReturnValue('ds-uid'); + + // Act + const builder = rulerUrlBuilder(config); + + const rules = builder.rules(); + const namespace = builder.namespace('test-ns'); + const group = builder.namespaceGroup('test-ns', 'test-gr'); + + // Assert + expect(rules.path).toBe('/api/ruler/ds-uid/api/v1/rules'); + expect(rules.params).toMatchObject({ subtype: 'mimir' }); + + expect(namespace.path).toBe('/api/ruler/ds-uid/api/v1/rules/test-ns'); + expect(namespace.params).toMatchObject({ subtype: 'mimir' }); + + expect(group.path).toBe('/api/ruler/ds-uid/api/v1/rules/test-ns/test-gr'); + expect(group.params).toMatchObject({ subtype: 'mimir' }); + }); + + it('Should append source=rules parameter when custom ruler enabled', () => { + // Arrange + const config: RulerDataSourceConfig = { + dataSourceName: 'Cortex v2', + apiVersion: 'config', + }; + + mocks.getDatasourceAPIId.mockReturnValue('ds-uid'); + + // Act + const builder = rulerUrlBuilder(config); + + const rules = builder.rules(); + const namespace = builder.namespace('test-ns'); + const group = builder.namespaceGroup('test-ns', 'test-gr'); + + // Assert + expect(rules.params).toMatchObject({ subtype: 'mimir' }); + expect(namespace.params).toMatchObject({ subtype: 'mimir' }); + expect(group.params).toMatchObject({ subtype: 'mimir' }); + }); + + it('Should append dashboard_uid and panel_id for rules endpoint when specified', () => { + // Arrange + const config: RulerDataSourceConfig = { + dataSourceName: 'Cortex v2', + apiVersion: 'config', + }; + + mocks.getDatasourceAPIId.mockReturnValue('ds-uid'); + + // Act + const builder = rulerUrlBuilder(config); + const rules = builder.rules({ dashboardUID: 'dashboard-uid', panelId: 1234 }); + + // Assert + expect(rules.params).toMatchObject({ dashboard_uid: 'dashboard-uid', panel_id: '1234', subtype: 'mimir' }); + }); +}); diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index ac3edeacf1c..c5c82f6317e 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -4,25 +4,62 @@ import { FetchResponse, getBackendSrv } from '@grafana/runtime'; import { PostableRulerRuleGroupDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; +import { RulerDataSourceConfig } from 'app/types/unified-alerting'; +import { prepareRulesFilterQueryParams } from './prometheus'; interface ErrorResponseMessage { message?: string; error?: string; } -// upsert a rule group. use this to update rules +export interface RulerRequestUrl { + path: string; + params?: Record; +} + +export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) { + const grafanaServerPath = `/api/ruler/${getDatasourceAPIId(rulerConfig.dataSourceName)}`; + + const rulerPath = `${grafanaServerPath}/api/v1/rules`; + const rulerSearchParams = new URLSearchParams(); + + rulerSearchParams.set('subtype', rulerConfig.apiVersion === 'legacy' ? 'cortex' : 'mimir'); + + return { + rules: (filter?: FetchRulerRulesFilter): RulerRequestUrl => { + const params = prepareRulesFilterQueryParams(rulerSearchParams, filter); + + return { + path: `${rulerPath}`, + params: params, + }; + }, + namespace: (namespace: string): RulerRequestUrl => ({ + path: `${rulerPath}/${encodeURIComponent(namespace)}`, + params: Object.fromEntries(rulerSearchParams), + }), + namespaceGroup: (namespace: string, group: string): RulerRequestUrl => ({ + path: `${rulerPath}/${encodeURIComponent(namespace)}/${encodeURIComponent(group)}`, + params: Object.fromEntries(rulerSearchParams), + }), + }; +} + +// upsert a rule group. use this to update rule export async function setRulerRuleGroup( - dataSourceName: string, + rulerConfig: RulerDataSourceConfig, namespace: string, group: PostableRulerRuleGroupDTO ): Promise { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); await lastValueFrom( getBackendSrv().fetch({ method: 'POST', - url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + url: path, data: group, showErrorAlert: false, showSuccessAlert: false, + params, }) ); } @@ -33,59 +70,51 @@ export interface FetchRulerRulesFilter { } // fetch all ruler rule namespaces and included groups -export async function fetchRulerRules(dataSourceName: string, filter?: FetchRulerRulesFilter) { - if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { - throw new Error('Filtering by dashboard UID is not supported for cloud rules sources.'); +export async function fetchRulerRules(rulerConfig: RulerDataSourceConfig, filter?: FetchRulerRulesFilter) { + if (filter?.dashboardUID && rulerConfig.dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { + throw new Error('Filtering by dashboard UID is only supported by Grafana.'); } - const params: Record = {}; - if (filter?.dashboardUID) { - params['dashboard_uid'] = filter.dashboardUID; - if (filter.panelId) { - params['panel_id'] = String(filter.panelId); - } - } - return rulerGetRequest( - `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, - {}, - params - ); + // TODO Move params creation to the rules function + const { path: url, params } = rulerUrlBuilder(rulerConfig).rules(filter); + return rulerGetRequest(url, {}, params); } // fetch rule groups for a particular namespace // will throw with { status: 404 } if namespace does not exist -export async function fetchRulerRulesNamespace(dataSourceName: string, namespace: string) { - const result = await rulerGetRequest>( - `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, - {} - ); +export async function fetchRulerRulesNamespace(rulerConfig: RulerDataSourceConfig, namespace: string) { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); + const result = await rulerGetRequest>(path, {}, params); return result[namespace] || []; } // fetch a particular rule group // will throw with { status: 404 } if rule group does not exist -export async function fetchRulerRulesGroup( - dataSourceName: string, - namespace: string, - group: string -): Promise { +export async function fetchTestRulerRulesGroup(dataSourceName: string): Promise { return rulerGetRequest( - `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( - namespace - )}/${encodeURIComponent(group)}`, + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/test/test`, null ); } -export async function deleteRulerRulesGroup(dataSourceName: string, namespace: string, groupName: string) { +export async function fetchRulerRulesGroup( + rulerConfig: RulerDataSourceConfig, + namespace: string, + group: string +): Promise { + const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group); + return rulerGetRequest(path, null, params); +} + +export async function deleteRulerRulesGroup(rulerConfig: RulerDataSourceConfig, namespace: string, groupName: string) { + const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, groupName); await lastValueFrom( getBackendSrv().fetch({ - url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( - namespace - )}/${encodeURIComponent(groupName)}`, + url: path, method: 'DELETE', showSuccessAlert: false, showErrorAlert: false, + params, }) ); } @@ -144,13 +173,15 @@ function isCortexErrorResponse(error: FetchResponse) { ); } -export async function deleteNamespace(dataSourceName: string, namespace: string): Promise { +export async function deleteNamespace(rulerConfig: RulerDataSourceConfig, namespace: string): Promise { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); await lastValueFrom( getBackendSrv().fetch({ method: 'DELETE', - url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + url: path, showErrorAlert: false, showSuccessAlert: false, + params, }) ); } diff --git a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx index 1d04dd52ff4..fa95540ad4a 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx @@ -12,12 +12,17 @@ import { isRulerNotSupportedResponse } from '../../utils/rules'; export function RuleListErrors(): ReactElement { const [expanded, setExpanded] = useState(false); const [closed, setClosed] = useLocalStorage('grafana.unifiedalerting.hideErrors', false); + const dataSourceConfigRequests = useUnifiedAlertingSelector((state) => state.dataSources); const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const styles = useStyles2(getStyles); const errors = useMemo((): JSX.Element[] => { - const [promRequestErrors, rulerRequestErrors] = [promRuleRequests, rulerRuleRequests].map((requests) => + const [dataSourceConfigErrors, promRequestErrors, rulerRequestErrors] = [ + dataSourceConfigRequests, + promRuleRequests, + rulerRuleRequests, + ].map((requests) => getRulesDataSources().reduce>( (result, dataSource) => { const error = requests[dataSource.name]?.error; @@ -41,6 +46,15 @@ export function RuleListErrors(): ReactElement { result.push(<>Failed to load Grafana rules config: {grafanaRulerError.message || 'Unknown error.'}); } + dataSourceConfigErrors.forEach(({ dataSource, error }) => { + result.push( + <> + Failed to load the data source configuration for{' '} + {dataSource.name}: {error.message || 'Unknown error.'} + + ); + }); + promRequestErrors.forEach(({ dataSource, error }) => result.push( <> @@ -60,7 +74,7 @@ export function RuleListErrors(): ReactElement { ); return result; - }, [promRuleRequests, rulerRuleRequests]); + }, [dataSourceConfigRequests, promRuleRequests, rulerRuleRequests]); return ( <> diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index 7809c75f1bf..79c517592dc 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -1,13 +1,14 @@ -import { useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; import { CombinedRule, RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting'; -import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux'; -import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; -import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; -import { fetchPromRulesAction, fetchRulerRulesAction } from '../state/actions'; import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useAsync } from 'react-use'; +import { fetchPromAndRulerRulesAction } from '../state/actions'; +import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux'; import * as ruleId from '../utils/rule-id'; import { isRulerNotSupportedResponse } from '../utils/rules'; +import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; export function useCombinedRule( identifier: RuleIdentifier | undefined, @@ -82,17 +83,16 @@ function useCombinedRulesLoader(rulesSourceName: string | undefined): AsyncReque const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulerRuleRequest = getRequestState(rulesSourceName, rulerRuleRequests); - useEffect(() => { + const { loading } = useAsync(async () => { if (!rulesSourceName) { return; } - dispatch(fetchPromRulesAction({ rulesSourceName })); - dispatch(fetchRulerRulesAction({ rulesSourceName })); + await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName })); }, [dispatch, rulesSourceName]); return { - loading: promRuleRequest.loading || rulerRuleRequest.loading, + loading, error: promRuleRequest.error ?? isRulerNotSupportedResponse(rulerRuleRequest) ? undefined : rulerRuleRequest.error, dispatched: promRuleRequest.dispatched && rulerRuleRequest.dispatched, }; diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts index 4a28c44004d..97695319adf 100644 --- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts +++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts @@ -1,12 +1,8 @@ import { contextSrv } from 'app/core/services/context_srv'; -import { isGrafanaRulerRule } from '../utils/rules'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import { isGrafanaRulerRule } from '../utils/rules'; import { useFolder } from './useFolder'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; -import { useDispatch } from 'react-redux'; -import { useEffect } from 'react'; -import { checkIfLotexSupportsEditingRulesAction } from '../state/actions'; -import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; interface ResultBag { isEditable?: boolean; @@ -14,18 +10,11 @@ interface ResultBag { } export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag { - const checkEditingRequests = useUnifiedAlertingSelector((state) => state.lotexSupportsRuleEditing); - const dispatch = useDispatch(); + const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined; const { folder, loading } = useFolder(folderUID); - useEffect(() => { - if (checkEditingRequests[rulesSourceName] === undefined && rulesSourceName !== GRAFANA_RULES_SOURCE_NAME) { - dispatch(checkIfLotexSupportsEditingRulesAction(rulesSourceName)); - } - }, [rulesSourceName, checkEditingRequests, dispatch]); - if (!rule) { return { isEditable: false, loading: false }; } @@ -45,7 +34,7 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): // prom rules are only editable by users with Editor role and only if rules source supports editing return { - isEditable: contextSrv.isEditor && !!checkEditingRequests[rulesSourceName]?.result, - loading: !!checkEditingRequests[rulesSourceName]?.loading, + isEditable: contextSrv.isEditor && Boolean(dataSources[rulesSourceName]?.result?.rulerConfig), + loading: dataSources[rulesSourceName]?.loading, }; } diff --git a/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts b/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts index af22c1e5769..e5d49d680ac 100644 --- a/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts +++ b/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts @@ -1,23 +1,17 @@ import { DataSourceInstanceSettings } from '@grafana/data'; -import { useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { checkIfLotexSupportsEditingRulesAction } from '../state/actions'; -import { getRulesDataSources } from '../utils/datasource'; +import { PromBasedDataSource } from 'app/types/unified-alerting'; +import { getDataSourceByName } from '../utils/datasource'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; export function useRulesSourcesWithRuler(): DataSourceInstanceSettings[] { - const checkEditingRequests = useUnifiedAlertingSelector((state) => state.lotexSupportsRuleEditing); - const dispatch = useDispatch(); + const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); + const dataSourcesWithRuler = Object.values(dataSources) + .map((ds) => ds.result) + .filter((ds): ds is PromBasedDataSource => Boolean(ds?.rulerConfig)); // try fetching rules for each prometheus to see if it has ruler - useEffect(() => { - getRulesDataSources() - .filter((ds) => checkEditingRequests[ds.name] === undefined) - .forEach((ds) => dispatch(checkIfLotexSupportsEditingRulesAction(ds.name))); - }, [dispatch, checkEditingRequests]); - return useMemo( - () => getRulesDataSources().filter((ds) => checkEditingRequests[ds.name]?.result), - [checkEditingRequests] - ); + return dataSourcesWithRuler + .map((ds) => getDataSourceByName(ds.name)) + .filter((dsConfig): dsConfig is DataSourceInstanceSettings => Boolean(dsConfig)); } diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index c8c5113ef4a..92df3168f92 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -2,87 +2,100 @@ import { getBackendSrv, locationService } from '@grafana/runtime'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { AlertmanagerAlert, - ExternalAlertmanagerConfig, AlertManagerCortexConfig, AlertmanagerGroup, + ExternalAlertmanagerConfig, ExternalAlertmanagersResponse, Receiver, Silence, SilenceCreatePayload, TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; -import { FolderDTO, NotifierDTO, ThunkResult } from 'app/types'; +import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError'; +import { FolderDTO, NotifierDTO, StoreState, ThunkResult } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace, + PromBasedDataSource, RuleIdentifier, RuleNamespace, + RulerDataSourceConfig, RuleWithLocation, StateHistoryItem, } from 'app/types/unified-alerting'; +import { PromApplication, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { isEmpty } from 'lodash'; import { - PostableRulerRuleGroupDTO, - RulerGrafanaRuleDTO, - RulerRuleGroupDTO, - RulerRulesConfigDTO, -} from 'app/types/unified-alerting-dto'; -import { fetchNotifiers } from '../api/grafana'; -import { fetchAnnotations } from '../api/annotations'; -import { + addAlertManagers, + createOrUpdateSilence, + deleteAlertManagerConfig, expireSilence, + fetchAlertGroups, fetchAlertManagerConfig, fetchAlerts, - fetchAlertGroups, - fetchSilences, - createOrUpdateSilence, - updateAlertManagerConfig, - fetchStatus, - deleteAlertManagerConfig, - testReceivers, - addAlertManagers, - fetchExternalAlertmanagers, fetchExternalAlertmanagerConfig, + fetchExternalAlertmanagers, + fetchSilences, + fetchStatus, + testReceivers, + updateAlertManagerConfig, } from '../api/alertmanager'; +import { fetchAnnotations } from '../api/annotations'; +import { fetchBuildInfo } from '../api/buildInfo'; +import { fetchNotifiers } from '../api/grafana'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, - fetchRulerRulesGroup, - fetchRulerRulesNamespace, FetchRulerRulesFilter, setRulerRuleGroup, } from '../api/ruler'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; +import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import { getAllRulesSourceNames, + getRulesDataSource, getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, - isGrafanaRulesSource, isVanillaPrometheusAlertManagerDataSource, } from '../utils/datasource'; import { makeAMLink, retryWhile } from '../utils/misc'; -import { withAppEvents, withSerializedError } from '../utils/redux'; -import { formValuesToRulerRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form'; -import { - isCloudRuleIdentifier, - isGrafanaRuleIdentifier, - isGrafanaRulerRule, - isPrometheusRuleIdentifier, - isRulerNotSupportedResponse, -} from '../utils/rules'; -import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute, isFetchError } from '../utils/alertmanager'; +import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; import * as ruleId from '../utils/rule-id'; -import { isEmpty } from 'lodash'; -import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError'; -import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; +import { getRulerClient } from '../utils/rulerClient'; +import { isRulerNotSupportedResponse } from '../utils/rules'; const FETCH_CONFIG_RETRY_TIMEOUT = 30 * 1000; +function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) { + const dataSources = (getState() as StoreState).unifiedAlerting.dataSources; + const dsConfig = dataSources[rulesSourceName]?.result; + if (!dsConfig) { + throw new Error(`Data source configuration is not available for "${rulesSourceName}" data source`); + } + + return dsConfig; +} + +function getDataSourceRulerConfig(getState: () => unknown, rulesSourceName: string) { + const dsConfig = getDataSourceConfig(getState, rulesSourceName); + if (!dsConfig.rulerConfig) { + throw new Error(`Ruler API is not available for ${rulesSourceName}`); + } + + return dsConfig.rulerConfig; +} + export const fetchPromRulesAction = createAsyncThunk( 'unifiedalerting/fetchPromRules', - ({ rulesSourceName, filter }: { rulesSourceName: string; filter?: FetchPromRulesFilter }): Promise => - withSerializedError(fetchRules(rulesSourceName, filter)) + async ( + { rulesSourceName, filter }: { rulesSourceName: string; filter?: FetchPromRulesFilter }, + thunkAPI + ): Promise => { + await thunkAPI.dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); + return await withSerializedError(fetchRules(rulesSourceName, filter)); + } ); export const fetchAlertManagerConfigAction = createAsyncThunk( @@ -138,17 +151,34 @@ export const fetchExternalAlertmanagersConfigAction = createAsyncThunk( export const fetchRulerRulesAction = createAsyncThunk( 'unifiedalerting/fetchRulerRules', - ({ - rulesSourceName, - filter, - }: { - rulesSourceName: string; - filter?: FetchRulerRulesFilter; - }): Promise => { - return withSerializedError(fetchRulerRules(rulesSourceName, filter)); + async ( + { + rulesSourceName, + filter, + }: { + rulesSourceName: string; + filter?: FetchRulerRulesFilter; + }, + { dispatch, getState } + ): Promise => { + await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); + const rulerConfig = getDataSourceRulerConfig(getState, rulesSourceName); + return await withSerializedError(fetchRulerRules(rulerConfig, filter)); } ); +export function fetchPromAndRulerRulesAction({ rulesSourceName }: { rulesSourceName: string }): ThunkResult { + return async (dispatch, getState) => { + await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); + const dsConfig = getDataSourceConfig(getState, rulesSourceName); + + await dispatch(fetchPromRulesAction({ rulesSourceName })); + if (dsConfig.rulerConfig) { + await dispatch(fetchRulerRulesAction({ rulesSourceName })); + } + }; +} + export const fetchSilencesAction = createAsyncThunk( 'unifiedalerting/fetchSilences', (alertManagerSourceName: string): Promise => { @@ -167,14 +197,81 @@ export function fetchRulerRulesIfNotFetchedYet(rulesSourceName: string): ThunkRe }; } +export function fetchAllPromBuildInfoAction(): ThunkResult> { + return async (dispatch) => { + const allRequests = getAllRulesSourceNames().map((rulesSourceName) => + dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })) + ); + + await Promise.allSettled(allRequests); + }; +} + +export const fetchRulesSourceBuildInfoAction = createAsyncThunk( + 'unifiedalerting/fetchPromBuildinfo', + async ({ rulesSourceName }: { rulesSourceName: string }): Promise => { + return withSerializedError( + (async (): Promise => { + if (rulesSourceName === GRAFANA_RULES_SOURCE_NAME) { + return { + name: GRAFANA_RULES_SOURCE_NAME, + id: GRAFANA_RULES_SOURCE_NAME, + rulerConfig: { + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + apiVersion: 'legacy', + }, + }; + } + + const ds = getRulesDataSource(rulesSourceName); + if (!ds) { + throw new Error(`Missing data source configuration for ${rulesSourceName}`); + } + + const { id, name } = ds; + const buildInfo = await fetchBuildInfo(name); + + const rulerConfig: RulerDataSourceConfig | undefined = buildInfo.features.rulerApiEnabled + ? { + dataSourceName: name, + apiVersion: buildInfo.application === PromApplication.Cortex ? 'legacy' : 'config', + } + : undefined; + + return { + name: name, + id: id, + rulerConfig, + }; + })() + ); + }, + { + condition: ({ rulesSourceName }, { getState }) => { + const dataSources: AsyncRequestMapSlice = (getState() as StoreState).unifiedAlerting + .dataSources; + const hasLoaded = Boolean(dataSources[rulesSourceName]?.result); + return !hasLoaded; + }, + } +); + export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult { - return (dispatch, getStore) => { - const { promRules, rulerRules } = getStore().unifiedAlerting; + return async (dispatch, getStore) => { + await dispatch(fetchAllPromBuildInfoAction()); + + const { promRules, rulerRules, dataSources } = getStore().unifiedAlerting; + getAllRulesSourceNames().map((rulesSourceName) => { + const dataSourceConfig = dataSources[rulesSourceName].result; + if (!dataSourceConfig) { + return; + } + if (force || !promRules[rulesSourceName]?.loading) { dispatch(fetchPromRulesAction({ rulesSourceName })); } - if (force || !rulerRules[rulesSourceName]?.loading) { + if ((force || !rulerRules[rulesSourceName]?.loading) && dataSourceConfig.rulerConfig) { dispatch(fetchRulerRulesAction({ rulesSourceName })); } }); @@ -182,7 +279,7 @@ export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult { - return (dispatch, getStore) => { + return async (dispatch, getStore) => { const { promRules } = getStore().unifiedAlerting; getAllRulesSourceNames().map((rulesSourceName) => { if (force || !promRules[rulesSourceName]?.loading) { @@ -192,97 +289,26 @@ export function fetchAllPromRulesAction(force = false): ThunkResult { }; } -async function findEditableRule(ruleIdentifier: RuleIdentifier): Promise { - if (isGrafanaRuleIdentifier(ruleIdentifier)) { - const namespaces = await fetchRulerRules(GRAFANA_RULES_SOURCE_NAME); - // find namespace and group that contains the uid for the rule - for (const [namespace, groups] of Object.entries(namespaces)) { - for (const group of groups) { - const rule = group.rules.find( - (rule) => isGrafanaRulerRule(rule) && rule.grafana_alert?.uid === ruleIdentifier.uid - ); - if (rule) { - return { - group, - ruleSourceName: GRAFANA_RULES_SOURCE_NAME, - namespace: namespace, - rule, - }; - } - } - } - } - - if (isCloudRuleIdentifier(ruleIdentifier)) { - const { ruleSourceName, namespace, groupName } = ruleIdentifier; - const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName); - - if (!group) { - return null; - } - - const rule = group.rules.find((rule) => { - const identifier = ruleId.fromRulerRule(ruleSourceName, namespace, group.name, rule); - return ruleId.equal(identifier, ruleIdentifier); - }); - - if (!rule) { - return null; - } - - return { - group, - ruleSourceName, - namespace, - rule, - }; - } - - if (isPrometheusRuleIdentifier(ruleIdentifier)) { - throw new Error('Native prometheus rules can not be edited in grafana.'); - } - - return null; -} - export const fetchEditableRuleAction = createAsyncThunk( 'unifiedalerting/fetchEditableRule', - (ruleIdentifier: RuleIdentifier): Promise => - withSerializedError(findEditableRule(ruleIdentifier)) + (ruleIdentifier: RuleIdentifier, thunkAPI): Promise => { + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, ruleIdentifier.ruleSourceName); + return withSerializedError(getRulerClient(rulerConfig).findEditableRule(ruleIdentifier)); + } ); -async function deleteRule(ruleWithLocation: RuleWithLocation): Promise { - const { ruleSourceName, namespace, group, rule } = ruleWithLocation; - // in case of GRAFANA, each group implicitly only has one rule. delete the group. - if (isGrafanaRulesSource(ruleSourceName)) { - await deleteRulerRulesGroup(GRAFANA_RULES_SOURCE_NAME, namespace, group.name); - return; - } - // in case of CLOUD - // it was the last rule, delete the entire group - if (group.rules.length === 1) { - await deleteRulerRulesGroup(ruleSourceName, namespace, group.name); - return; - } - // post the group with rule removed - await setRulerRuleGroup(ruleSourceName, namespace, { - ...group, - rules: group.rules.filter((r) => r !== rule), - }); -} - export function deleteRulesGroupAction( namespace: CombinedRuleNamespace, ruleGroup: CombinedRuleGroup ): ThunkResult { - return async (dispatch) => { + return async (dispatch, getState) => { withAppEvents( (async () => { const sourceName = getRulesSourceName(namespace.rulesSource); + const rulerConfig = getDataSourceRulerConfig(getState, sourceName); - await deleteRulerRulesGroup(sourceName, namespace.name, ruleGroup.name); - dispatch(fetchRulerRulesAction({ rulesSourceName: sourceName })); - dispatch(fetchPromRulesAction({ rulesSourceName: sourceName })); + await deleteRulerRulesGroup(rulerConfig, namespace.name, ruleGroup.name); + await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: sourceName })); })(), { successMessage: 'Group deleted' } ); @@ -297,17 +323,19 @@ export function deleteRuleAction( * fetch the rules group from backend, delete group if it is found and+ * reload ruler rules */ - return async (dispatch) => { + return async (dispatch, getState) => { withAppEvents( (async () => { - const ruleWithLocation = await findEditableRule(ruleIdentifier); + const rulerConfig = getDataSourceRulerConfig(getState, ruleIdentifier.ruleSourceName); + const rulerClient = getRulerClient(rulerConfig); + const ruleWithLocation = await rulerClient.findEditableRule(ruleIdentifier); + if (!ruleWithLocation) { throw new Error('Rule not found.'); } - await deleteRule(ruleWithLocation); + await rulerClient.deleteRule(ruleWithLocation); // refetch rules for this rules source - dispatch(fetchRulerRulesAction({ rulesSourceName: ruleWithLocation.ruleSourceName })); - dispatch(fetchPromRulesAction({ rulesSourceName: ruleWithLocation.ruleSourceName })); + await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: ruleWithLocation.ruleSourceName })); if (options.navigateTo) { locationService.replace(options.navigateTo); @@ -320,147 +348,42 @@ export function deleteRuleAction( }; } -async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise { - const { dataSourceName, group, namespace } = values; - const formRule = formValuesToRulerRuleDTO(values); - if (dataSourceName && group && namespace) { - // if we're updating a rule... - if (existing) { - // refetch it so we always have the latest greatest - const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); - if (!freshExisting) { - throw new Error('Rule not found.'); - } - // if namespace or group was changed, delete the old rule - if (freshExisting.namespace !== namespace || freshExisting.group.name !== group) { - await deleteRule(freshExisting); - } else { - // if same namespace or group, update the group replacing the old rule with new - const payload = { - ...freshExisting.group, - rules: freshExisting.group.rules.map((existingRule) => - existingRule === freshExisting.rule ? formRule : existingRule - ), - }; - await setRulerRuleGroup(dataSourceName, namespace, payload); - return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); - } - } - - // if creating new rule or existing rule was in a different namespace/group, create new rule in target group - - const targetGroup = await fetchRulerRulesGroup(dataSourceName, namespace, group); - - const payload: RulerRuleGroupDTO = targetGroup - ? { - ...targetGroup, - rules: [...targetGroup.rules, formRule], - } - : { - name: group, - rules: [formRule], - }; - - await setRulerRuleGroup(dataSourceName, namespace, payload); - return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); - } else { - throw new Error('Data source and location must be specified'); - } -} - -async function saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise { - const { folder, evaluateEvery } = values; - const formRule = formValuesToRulerGrafanaRuleDTO(values); - - if (!folder) { - throw new Error('Folder must be specified'); - } - - // updating an existing rule... - if (existing) { - // refetch it to be sure we have the latest - const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); - if (!freshExisting) { - throw new Error('Rule not found.'); - } - - // if same folder, repost the group with updated rule - if (freshExisting.namespace === folder.title) { - const uid = (freshExisting.rule as RulerGrafanaRuleDTO).grafana_alert.uid!; - formRule.grafana_alert.uid = uid; - await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, freshExisting.namespace, { - name: freshExisting.group.name, - interval: evaluateEvery, - rules: [formRule], - }); - return { uid }; - } - } - - // if creating new rule or folder was changed, create rule in a new group - const targetFolderGroups = await fetchRulerRulesNamespace(GRAFANA_RULES_SOURCE_NAME, folder.title); - - // set group name to rule name, but be super paranoid and check that this group does not already exist - const groupName = getUniqueGroupName(values.name, targetFolderGroups); - formRule.grafana_alert.title = groupName; - - const payload: PostableRulerRuleGroupDTO = { - name: groupName, - interval: evaluateEvery, - rules: [formRule], - }; - await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, payload); - - // now refetch this group to get the uid, hah - const result = await fetchRulerRulesGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, groupName); - const newUid = (result?.rules[0] as RulerGrafanaRuleDTO)?.grafana_alert?.uid; - if (newUid) { - // if folder has changed, delete the old one - if (existing) { - const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); - if (freshExisting && freshExisting.namespace !== folder.title) { - await deleteRule(freshExisting); - } - } - - return { uid: newUid }; - } else { - throw new Error('Failed to fetch created rule.'); - } -} - -export function getUniqueGroupName(currentGroupName: string, existingGroups: RulerRuleGroupDTO[]) { - let newGroupName = currentGroupName; - let idx = 1; - while (!!existingGroups.find((g) => g.name === newGroupName)) { - newGroupName = `${currentGroupName}-${++idx}`; - } - - return newGroupName; -} - export const saveRuleFormAction = createAsyncThunk( 'unifiedalerting/saveRuleForm', - ({ - values, - existing, - redirectOnSave, - }: { - values: RuleFormValues; - existing?: RuleWithLocation; - redirectOnSave?: string; - }): Promise => + ( + { + values, + existing, + redirectOnSave, + }: { + values: RuleFormValues; + existing?: RuleWithLocation; + redirectOnSave?: string; + }, + thunkAPI + ): Promise => withAppEvents( withSerializedError( (async () => { const { type } = values; + + // TODO getRulerConfig should be smart enough to provide proper rulerClient implementation + // For the dataSourceName specified // in case of system (cortex/loki) let identifier: RuleIdentifier; if (type === RuleFormType.cloudAlerting || type === RuleFormType.cloudRecording) { - identifier = await saveLotexRule(values, existing); + if (!values.dataSourceName) { + throw new Error('The Data source has not been defined.'); + } + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, values.dataSourceName); + const rulerClient = getRulerClient(rulerConfig); + identifier = await rulerClient.saveLotexRule(values, existing); + // in case of grafana managed } else if (type === RuleFormType.grafana) { - identifier = await saveGrafanaRule(values, existing); + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME); + const rulerClient = getRulerClient(rulerConfig); + identifier = await rulerClient.saveGrafanaRule(values, existing); } else { throw new Error('Unexpected rule form type'); } @@ -651,33 +574,6 @@ export const fetchAlertGroupsAction = createAsyncThunk( } ); -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 - e.data.message?.includes(RULER_NOT_SUPPORTED_MSG) // ruler api not supported - ) { - return false; - } - throw e; - } - })(), - { - errorMessage: `Failed to determine if "${rulesSourceName}" allows editing rules`, - } - ) -); - export const deleteAlertManagerConfigAction = createAsyncThunk( 'unifiedalerting/deleteAlertManagerConfig', async (alertManagerSourceName: string, thunkAPI): Promise => { @@ -767,8 +663,10 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( if (options.rulesSourceName === GRAFANA_RULES_SOURCE_NAME) { throw new Error(`this action does not support Grafana rules`); } + + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName); // fetch rules and perform sanity checks - const rulesResult = await fetchRulerRules(rulesSourceName); + const rulesResult = await fetchRulerRules(rulerConfig); if (!rulesResult[namespaceName]) { throw new Error(`Namespace "${namespaceName}" not found.`); } @@ -794,7 +692,7 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( if (newNamespaceName !== namespaceName) { for (const group of rulesResult[namespaceName]) { await setRulerRuleGroup( - rulesSourceName, + rulerConfig, newNamespaceName, group.name === groupName ? { @@ -805,19 +703,19 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( : group ); } - await deleteNamespace(rulesSourceName, namespaceName); + await deleteNamespace(rulerConfig, namespaceName); // if only modifying group... } else { // save updated group - await setRulerRuleGroup(rulesSourceName, namespaceName, { + await setRulerRuleGroup(rulerConfig, namespaceName, { ...existingGroup, name: newGroupName, interval: groupInterval, }); // if group name was changed, delete old group if (newGroupName !== groupName) { - await deleteRulerRulesGroup(rulesSourceName, namespaceName, groupName); + await deleteRulerRulesGroup(rulerConfig, namespaceName, groupName); } } diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 65f1aa8b157..30d2ff1d445 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -1,28 +1,33 @@ import { combineReducers } from 'redux'; import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux'; import { + createOrUpdateSilenceAction, + deleteAlertManagerConfigAction, + fetchAlertGroupsAction, fetchAlertManagerConfigAction, fetchAmAlertsAction, fetchEditableRuleAction, + fetchExternalAlertmanagersAction, + fetchExternalAlertmanagersConfigAction, + fetchFolderAction, + fetchGrafanaAnnotationsAction, fetchGrafanaNotifiersAction, fetchPromRulesAction, fetchRulerRulesAction, + fetchRulesSourceBuildInfoAction, fetchSilencesAction, saveRuleFormAction, - updateAlertManagerConfigAction, - createOrUpdateSilenceAction, - fetchFolderAction, - fetchAlertGroupsAction, - checkIfLotexSupportsEditingRulesAction, - deleteAlertManagerConfigAction, testReceiversAction, + updateAlertManagerConfigAction, updateLotexNamespaceAndGroupAction, - fetchExternalAlertmanagersAction, - fetchExternalAlertmanagersConfigAction, - fetchGrafanaAnnotationsAction, } from './actions'; export const reducer = combineReducers({ + dataSources: createAsyncMapSlice( + 'dataSources', + fetchRulesSourceBuildInfoAction, + ({ rulesSourceName }) => rulesSourceName + ).reducer, promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer, rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName) .reducer, @@ -49,11 +54,6 @@ export const reducer = combineReducers({ fetchAlertGroupsAction, (alertManagerSourceName) => alertManagerSourceName ).reducer, - lotexSupportsRuleEditing: createAsyncMapSlice( - 'lotexSupportsRuleEditing', - checkIfLotexSupportsEditingRulesAction, - (source) => source - ).reducer, testReceivers: createAsyncSlice('testReceivers', testReceiversAction).reducer, updateLotexNamespaceAndGroup: createAsyncSlice('updateLotexNamespaceAndGroup', updateLotexNamespaceAndGroupAction) .reducer, diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index 99bb03c9f2d..e494c62dda3 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -1,4 +1,4 @@ -import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; +import { DataSourceJsonData, DataSourceInstanceSettings } from '@grafana/data'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; import { RulesSource } from 'app/types/unified-alerting'; import { getAllDataSources } from './config'; @@ -20,6 +20,10 @@ export function getRulesDataSources() { .sort((a, b) => a.name.localeCompare(b.name)); } +export function getRulesDataSource(rulesSourceName: string) { + return getAllDataSources().find((x) => x.name === rulesSourceName); +} + export function getAlertManagerDataSources() { return getAllDataSources() .filter((ds) => ds.type === DataSourceType.Alertmanager) diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index 6b8d34f8c78..08454d58f12 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -19,7 +19,7 @@ export function fromRulerRule( rule: RulerRuleDTO ): RuleIdentifier { if (isGrafanaRulerRule(rule)) { - return { uid: rule.grafana_alert.uid! }; + return { uid: rule.grafana_alert.uid!, ruleSourceName: 'grafana' }; } return { ruleSourceName, @@ -99,7 +99,7 @@ export function parse(value: string, decodeFromUri = false): RuleIdentifier { const parts = source.split('$'); if (parts.length === 1) { - return { uid: value }; + return { uid: value, ruleSourceName: 'grafana' }; } if (parts.length === 5) { diff --git a/public/app/features/alerting/unified/state/actions.test.ts b/public/app/features/alerting/unified/utils/rulerClient.test.ts similarity index 94% rename from public/app/features/alerting/unified/state/actions.test.ts rename to public/app/features/alerting/unified/utils/rulerClient.test.ts index 99ac8dc0e06..ec5771833cf 100644 --- a/public/app/features/alerting/unified/state/actions.test.ts +++ b/public/app/features/alerting/unified/utils/rulerClient.test.ts @@ -1,5 +1,5 @@ import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; -import { getUniqueGroupName } from './actions'; +import { getUniqueGroupName } from './rulerClient'; describe('getUniqueGroupName', () => { it('Should return the original value when there are no duplicates', () => { diff --git a/public/app/features/alerting/unified/utils/rulerClient.ts b/public/app/features/alerting/unified/utils/rulerClient.ts new file mode 100644 index 00000000000..89eb8e6c286 --- /dev/null +++ b/public/app/features/alerting/unified/utils/rulerClient.ts @@ -0,0 +1,229 @@ +import { RuleIdentifier, RulerDataSourceConfig, RuleWithLocation } from 'app/types/unified-alerting'; +import { PostableRulerRuleGroupDTO, RulerGrafanaRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; +import { + deleteRulerRulesGroup, + fetchRulerRulesGroup, + fetchRulerRulesNamespace, + fetchRulerRules, + setRulerRuleGroup, +} from '../api/ruler'; +import { RuleFormValues } from '../types/rule-form'; +import * as ruleId from '../utils/rule-id'; +import { GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource'; +import { formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO } from './rule-form'; +import { + isCloudRuleIdentifier, + isGrafanaRuleIdentifier, + isGrafanaRulerRule, + isPrometheusRuleIdentifier, +} from './rules'; + +export interface RulerClient { + findEditableRule(ruleIdentifier: RuleIdentifier): Promise; + deleteRule(ruleWithLocation: RuleWithLocation): Promise; + saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise; + saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise; +} + +export function getUniqueGroupName(currentGroupName: string, existingGroups: RulerRuleGroupDTO[]) { + let newGroupName = currentGroupName; + let idx = 1; + while (!!existingGroups.find((g) => g.name === newGroupName)) { + newGroupName = `${currentGroupName}-${++idx}`; + } + + return newGroupName; +} + +export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient { + const findEditableRule = async (ruleIdentifier: RuleIdentifier): Promise => { + if (isGrafanaRuleIdentifier(ruleIdentifier)) { + const namespaces = await fetchRulerRules(rulerConfig); + // find namespace and group that contains the uid for the rule + for (const [namespace, groups] of Object.entries(namespaces)) { + for (const group of groups) { + const rule = group.rules.find( + (rule) => isGrafanaRulerRule(rule) && rule.grafana_alert?.uid === ruleIdentifier.uid + ); + if (rule) { + return { + group, + ruleSourceName: GRAFANA_RULES_SOURCE_NAME, + namespace: namespace, + rule, + }; + } + } + } + } + + if (isCloudRuleIdentifier(ruleIdentifier)) { + const { ruleSourceName, namespace, groupName } = ruleIdentifier; + const group = await fetchRulerRulesGroup(rulerConfig, namespace, groupName); + + if (!group) { + return null; + } + + const rule = group.rules.find((rule) => { + const identifier = ruleId.fromRulerRule(ruleSourceName, namespace, group.name, rule); + return ruleId.equal(identifier, ruleIdentifier); + }); + + if (!rule) { + return null; + } + + return { + group, + ruleSourceName, + namespace, + rule, + }; + } + + if (isPrometheusRuleIdentifier(ruleIdentifier)) { + throw new Error('Native prometheus rules can not be edited in grafana.'); + } + + return null; + }; + + const deleteRule = async (ruleWithLocation: RuleWithLocation): Promise => { + const { ruleSourceName, namespace, group, rule } = ruleWithLocation; + // in case of GRAFANA, each group implicitly only has one rule. delete the group. + if (isGrafanaRulesSource(ruleSourceName)) { + await deleteRulerRulesGroup(rulerConfig, namespace, group.name); + return; + } + // in case of CLOUD + // it was the last rule, delete the entire group + if (group.rules.length === 1) { + await deleteRulerRulesGroup(rulerConfig, namespace, group.name); + return; + } + // post the group with rule removed + await setRulerRuleGroup(rulerConfig, namespace, { + ...group, + rules: group.rules.filter((r) => r !== rule), + }); + }; + + const saveLotexRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise => { + const { dataSourceName, group, namespace } = values; + const formRule = formValuesToRulerRuleDTO(values); + if (dataSourceName && group && namespace) { + // if we're updating a rule... + if (existing) { + // refetch it so we always have the latest greatest + const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); + if (!freshExisting) { + throw new Error('Rule not found.'); + } + // if namespace or group was changed, delete the old rule + if (freshExisting.namespace !== namespace || freshExisting.group.name !== group) { + await deleteRule(freshExisting); + } else { + // if same namespace or group, update the group replacing the old rule with new + const payload = { + ...freshExisting.group, + rules: freshExisting.group.rules.map((existingRule) => + existingRule === freshExisting.rule ? formRule : existingRule + ), + }; + await setRulerRuleGroup(rulerConfig, namespace, payload); + return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); + } + } + + // if creating new rule or existing rule was in a different namespace/group, create new rule in target group + + const targetGroup = await fetchRulerRulesGroup(rulerConfig, namespace, group); + + const payload: RulerRuleGroupDTO = targetGroup + ? { + ...targetGroup, + rules: [...targetGroup.rules, formRule], + } + : { + name: group, + rules: [formRule], + }; + + await setRulerRuleGroup(rulerConfig, namespace, payload); + return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); + } else { + throw new Error('Data source and location must be specified'); + } + }; + + const saveGrafanaRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise => { + const { folder, evaluateEvery } = values; + const formRule = formValuesToRulerGrafanaRuleDTO(values); + + if (!folder) { + throw new Error('Folder must be specified'); + } + + // updating an existing rule... + if (existing) { + // refetch it to be sure we have the latest + const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); + if (!freshExisting) { + throw new Error('Rule not found.'); + } + + // if same folder, repost the group with updated rule + if (freshExisting.namespace === folder.title) { + const uid = (freshExisting.rule as RulerGrafanaRuleDTO).grafana_alert.uid!; + formRule.grafana_alert.uid = uid; + await setRulerRuleGroup(rulerConfig, freshExisting.namespace, { + name: freshExisting.group.name, + interval: evaluateEvery, + rules: [formRule], + }); + return { uid, ruleSourceName: 'grafana' }; + } + } + + // if creating new rule or folder was changed, create rule in a new group + const targetFolderGroups = await fetchRulerRulesNamespace(rulerConfig, folder.title); + + // set group name to rule name, but be super paranoid and check that this group does not already exist + const groupName = getUniqueGroupName(values.name, targetFolderGroups); + formRule.grafana_alert.title = groupName; + + const payload: PostableRulerRuleGroupDTO = { + name: groupName, + interval: evaluateEvery, + rules: [formRule], + }; + await setRulerRuleGroup(rulerConfig, folder.title, payload); + + // now refetch this group to get the uid, hah + const result = await fetchRulerRulesGroup(rulerConfig, folder.title, groupName); + const newUid = (result?.rules[0] as RulerGrafanaRuleDTO)?.grafana_alert?.uid; + if (newUid) { + // if folder has changed, delete the old one + if (existing) { + const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); + if (freshExisting && freshExisting.namespace !== folder.title) { + await deleteRule(freshExisting); + } + } + + return { uid: newUid, ruleSourceName: 'grafana' }; + } else { + throw new Error('Failed to fetch created rule.'); + } + }; + + // Would be nice to somehow align checking of ruler type between different methods + // Maybe each datasource should have its own ruler client implementation + return { + findEditableRule, + deleteRule, + saveLotexRule, + saveGrafanaRule, + }; +} diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.tsx similarity index 91% rename from public/app/plugins/datasource/prometheus/datasource.ts rename to public/app/plugins/datasource/prometheus/datasource.tsx index ec3a815fcd4..307684109c0 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { cloneDeep, defaults } from 'lodash'; import { forkJoin, lastValueFrom, merge, Observable, of, OperatorFunction, pipe, throwError } from 'rxjs'; import { catchError, filter, map, tap } from 'rxjs/operators'; @@ -55,6 +56,9 @@ import { import { PrometheusVariableSupport } from './variables'; import PrometheusMetricFindQuery from './metric_find_query'; import { renderLegendFormat } from './legend'; +import { fetchDataSourceBuildInfo } from 'app/features/alerting/unified/api/buildInfo'; +import { PromApplication, PromBuildInfo } from 'app/types/unified-alerting-dto'; +import { Badge, BadgeColor, Tooltip } from '@grafana/ui'; export const ANNOTATION_QUERY_STEP_DEFAULT = '60s'; const GET_AND_POST_METADATA_ENDPOINTS = ['api/v1/query', 'api/v1/query_range', 'api/v1/series', 'api/v1/labels']; @@ -81,6 +85,8 @@ export class PrometheusDatasource lookupsDisabled: boolean; customQueryParameters: any; exemplarsAvailable: boolean; + subType: PromApplication; + rulerEnabled: boolean; constructor( instanceSettings: DataSourceInstanceSettings, @@ -91,6 +97,8 @@ export class PrometheusDatasource super(instanceSettings); this.type = 'prometheus'; + this.subType = PromApplication.Prometheus; + this.rulerEnabled = false; this.editorSrc = 'app/features/prometheus/partials/query.editor.html'; this.id = instanceSettings.id; this.url = instanceSettings.url!; @@ -791,6 +799,11 @@ export class PrometheusDatasource ); } + async getSubtitle(): Promise { + const buildInfo = await this.getBuildInfo(); + return buildInfo ? this.getBuildInfoMessage(buildInfo) : null; + } + async getTagKeys(options?: any) { if (options?.series) { // Get tags for the provided series only @@ -811,6 +824,82 @@ export class PrometheusDatasource return result?.data?.data?.map((value: any) => ({ text: value })) ?? []; } + async getBuildInfo() { + try { + const buildInfo = await fetchDataSourceBuildInfo(this); + return buildInfo; + } catch (error) { + // We don't want to break the rest of functionality if build info does not work correctly + return undefined; + } + } + + getBuildInfoMessage(buildInfo: PromBuildInfo) { + const enabled = ; + const disabled = ; + const unsupported = ( + +
+ +
+
+ ); + + const LOGOS = { + [PromApplication.Cortex]: '/public/app/plugins/datasource/prometheus/img/cortex_logo.svg', + [PromApplication.Mimir]: '/public/app/plugins/datasource/prometheus/img/mimir_logo.svg', + [PromApplication.Prometheus]: '/public/app/plugins/datasource/prometheus/img/prometheus_logo.svg', + }; + + const COLORS: Record = { + [PromApplication.Cortex]: 'blue', + [PromApplication.Mimir]: 'orange', + [PromApplication.Prometheus]: 'red', + }; + + // this will inform the user about what "subtype" the datasource is; Mimir, Cortex or vanilla Prometheus + const applicationSubType = ( + + {' '} + {buildInfo.application} + + } + color={COLORS[buildInfo.application ?? PromApplication.Prometheus]} + /> + ); + + return ( +
+
Type
+
{applicationSubType}
+ <> +
Ruler API
+ {/* Prometheus does not have a Ruler API – so show that it is not supported */} + {buildInfo.application === PromApplication.Prometheus &&
{unsupported}
} + {buildInfo.application !== PromApplication.Prometheus && ( +
{buildInfo.features.rulerApiEnabled ? enabled : disabled}
+ )} + +
+ ); + } + async testDatasource() { const now = new Date().getTime(); const request: DataQueryRequest = { @@ -828,12 +917,20 @@ export class PrometheusDatasource }, } as DataQueryRequest; + const buildInfo = await this.getBuildInfo(); + return lastValueFrom(this.query(request)) .then((res: DataQueryResponse) => { if (!res || !res.data || res.state !== LoadingState.Done) { return { status: 'error', message: `Error reading Prometheus: ${res?.error?.message}` }; } else { - return { status: 'success', message: 'Data source is working' }; + return { + status: 'success', + message: 'Data source is working', + details: buildInfo && { + verboseMessage: this.getBuildInfoMessage(buildInfo), + }, + }; } }) .catch((err: any) => { diff --git a/public/app/plugins/datasource/prometheus/img/cortex_logo.svg b/public/app/plugins/datasource/prometheus/img/cortex_logo.svg new file mode 100644 index 00000000000..48ec08428e1 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/img/cortex_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/datasource/prometheus/img/mimir_logo.svg b/public/app/plugins/datasource/prometheus/img/mimir_logo.svg new file mode 100644 index 00000000000..bd9605f592b --- /dev/null +++ b/public/app/plugins/datasource/prometheus/img/mimir_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/datasource/prometheus/plugin.json b/public/app/plugins/datasource/prometheus/plugin.json index 1c5f8af6abe..ab3c527c430 100644 --- a/public/app/plugins/datasource/prometheus/plugin.json +++ b/public/app/plugins/datasource/prometheus/plugin.json @@ -43,6 +43,16 @@ "method": "DELETE", "path": "/rules", "reqRole": "Editor" + }, + { + "method": "DELETE", + "path": "/config/v1/rules", + "reqRole": "Editor" + }, + { + "method": "POST", + "path": "/config/v1/rules", + "reqRole": "Editor" } ], "includes": [ diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index fe2d905f0de..64127def8d8 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -23,6 +23,33 @@ export enum PromRuleType { Alerting = 'alerting', Recording = 'recording', } +export enum PromApplication { + Cortex = 'Cortex', + Mimir = 'Mimir', + Prometheus = 'Prometheus', +} + +export interface PromBuildInfoResponse { + data: { + application?: string; + version: string; + revision: string; + features?: { + ruler_config_api?: 'true' | 'false'; + alertmanager_config_api?: 'true' | 'false'; + query_sharding?: 'true' | 'false'; + federated_rules?: 'true' | 'false'; + }; + }; + status: 'success'; +} + +export interface PromBuildInfo { + application?: PromApplication; + features: { + rulerApiEnabled: boolean; + }; +} interface PromRuleDTOBase { health: string; diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index ff16b43e9cf..52b2e903e54 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -117,6 +117,7 @@ export interface CloudRuleIdentifier { rulerRuleHash: number; } export interface GrafanaRuleIdentifier { + ruleSourceName: 'grafana'; uid: string; } @@ -173,3 +174,14 @@ export interface StateHistoryItem { avatarUrl: string; data: StateHistoryItemData; } + +export interface RulerDataSourceConfig { + dataSourceName: string; + apiVersion: 'legacy' | 'config'; +} + +export interface PromBasedDataSource { + name: string; + id: string | number; + rulerConfig?: RulerDataSourceConfig; +}