Alerting: Add support to distinguish Prometheus datasource subtypes (Mimir, Cortex and Vanilla Prometheus) (#46771)

* Add basic UI for custom ruler URL

* Add build info fetching for alerting data sources

* Add keeping data sources build info in the store

* Use data source build info to construct data source urls

* Remove unused code

* Add custom ruler support in prometheus api calls

* Migrate actions

* Use thunk condition to prevent multiple data source buildinfo fetches

* Unify prom and ruler rules loading

* Upgrade RuleEditor tests

* Upgrade RuleList tests

* Upgrade PanelAlertTab tests

* Upgrade actions tests

* Build info refactoring

* Get rid of lotex ruler support action

* Add prom ruler availability checking when the buildinfo is not available

* Add rulerUrlBuilder tests

* Improve prometheus data source validation, small build info refactoring

* Change prefix based on Prometheus subtype

* Use the correct path

* Revert config routing

* Add deprecation notice for /api/prom prefix

* Add tests to the datasource subtype

* Remove custom ruler support

* Remove deprecation notice

* Prevent fetching ruler rules when ruler api is not available

* Add build info tests

* Unify naming of ruler methods

* Fix test

* Change buildinfo data source validation

* Use strings for subtype params and unveil mimir

* organise imports

* frontend changes and wordsmithing

* fix test suite

* add a nicer verbose message for prometheus datasources

* detect Mimir datasource

* fix test

* fix buildinfo test for Mimir

* shrink vectors

* add some code documentation

* DRY prepareRulesFilterQueryParams

* clarify that Prometheus does not support managing rules

* Improve buildinfo error handling

Co-authored-by: gotjosh <josue.abreu@gmail.com>
Co-authored-by: gillesdemey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik 2022-04-04 19:30:17 +02:00 committed by GitHub
parent 4c6d2ce618
commit 6992d17924
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1514 additions and 498 deletions

View File

@ -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 {

View File

@ -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
}

View File

@ -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,

View File

@ -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());

View File

@ -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<ExistingRuleEditorProps> = ({ identifier }) => {
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>;
const RuleEditor: FC<RuleEditorProps> = ({ 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 <AlertWarning title="Cannot create rules">Sorry! You are not allowed to create rules.</AlertWarning>;
}
if (loading) {
return (
<Page.Contents>
<LoadingPlaceholder text="Loading..." />
</Page.Contents>
);
}
if (identifier) {
return <ExistingRuleEditor key={id} identifier={identifier} />;
}
if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) {
return <AlertWarning title="Cannot create rules">Sorry! You are not allowed to create rules.</AlertWarning>;
}
return <AlertRuleForm />;
};

View File

@ -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',
}
);
});
});
});

View File

@ -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');
});
});
});

View File

@ -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<PromBuildInfo> {
const { url, name } = dsSettings;
const response = await lastValueFrom(
getBackendSrv().fetch<PromBuildInfoResponse>({
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<PromBuildInfo> {
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
);
}

View File

@ -10,25 +10,53 @@ export interface FetchPromRulesFilter {
panelId?: number;
}
export async function fetchRules(dataSourceName: string, filter?: FetchPromRulesFilter): Promise<RuleNamespace[]> {
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<string, string> = {};
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<string, string> {
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<RuleNamespace[]> {
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<PromRulesResponse>({
url: `/api/prometheus/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`,
url,
params,
showErrorAlert: false,
showSuccessAlert: false,
params,
})
).catch((e) => {
if ('status' in e && e.status === 404) {

View File

@ -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' });
});
});

View File

@ -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<string, string>;
}
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<void> {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
await lastValueFrom(
getBackendSrv().fetch<unknown>({
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<string, string> = {};
if (filter?.dashboardUID) {
params['dashboard_uid'] = filter.dashboardUID;
if (filter.panelId) {
params['panel_id'] = String(filter.panelId);
}
}
return rulerGetRequest<RulerRulesConfigDTO>(
`/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<RulerRulesConfigDTO>(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<Record<string, RulerRuleGroupDTO[]>>(
`/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<Record<string, RulerRuleGroupDTO[]>>(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<RulerRuleGroupDTO | null> {
export async function fetchTestRulerRulesGroup(dataSourceName: string): Promise<RulerRuleGroupDTO | null> {
return rulerGetRequest<RulerRuleGroupDTO | null>(
`/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<RulerRuleGroupDTO | null> {
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
return rulerGetRequest<RulerRuleGroupDTO | null>(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<ErrorResponseMessage>) {
);
}
export async function deleteNamespace(dataSourceName: string, namespace: string): Promise<void> {
export async function deleteNamespace(rulerConfig: RulerDataSourceConfig, namespace: string): Promise<void> {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
await lastValueFrom(
getBackendSrv().fetch<unknown>({
method: 'DELETE',
url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`,
url: path,
showErrorAlert: false,
showSuccessAlert: false,
params,
})
);
}

View File

@ -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<Array<{ error: SerializedError; dataSource: DataSourceInstanceSettings }>>(
(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{' '}
<a href={`datasources/edit/${dataSource.uid}`}>{dataSource.name}</a>: {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 (
<>

View File

@ -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,
};

View File

@ -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,
};
}

View File

@ -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));
}

View File

@ -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<RuleNamespace[]> =>
withSerializedError(fetchRules(rulesSourceName, filter))
async (
{ rulesSourceName, filter }: { rulesSourceName: string; filter?: FetchPromRulesFilter },
thunkAPI
): Promise<RuleNamespace[]> => {
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<RulerRulesConfigDTO | null> => {
return withSerializedError(fetchRulerRules(rulesSourceName, filter));
async (
{
rulesSourceName,
filter,
}: {
rulesSourceName: string;
filter?: FetchRulerRulesFilter;
},
{ dispatch, getState }
): Promise<RulerRulesConfigDTO | null> => {
await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName }));
const rulerConfig = getDataSourceRulerConfig(getState, rulesSourceName);
return await withSerializedError(fetchRulerRules(rulerConfig, filter));
}
);
export function fetchPromAndRulerRulesAction({ rulesSourceName }: { rulesSourceName: string }): ThunkResult<void> {
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<Silence[]> => {
@ -167,14 +197,81 @@ export function fetchRulerRulesIfNotFetchedYet(rulesSourceName: string): ThunkRe
};
}
export function fetchAllPromBuildInfoAction(): ThunkResult<Promise<void>> {
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<PromBasedDataSource> => {
return withSerializedError<PromBasedDataSource>(
(async (): Promise<PromBasedDataSource> => {
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<PromBasedDataSource> = (getState() as StoreState).unifiedAlerting
.dataSources;
const hasLoaded = Boolean(dataSources[rulesSourceName]?.result);
return !hasLoaded;
},
}
);
export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult<void> {
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<void
}
export function fetchAllPromRulesAction(force = false): ThunkResult<void> {
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<void> {
};
}
async function findEditableRule(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> {
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<RuleWithLocation | null> =>
withSerializedError(findEditableRule(ruleIdentifier))
(ruleIdentifier: RuleIdentifier, thunkAPI): Promise<RuleWithLocation | null> => {
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, ruleIdentifier.ruleSourceName);
return withSerializedError(getRulerClient(rulerConfig).findEditableRule(ruleIdentifier));
}
);
async function deleteRule(ruleWithLocation: RuleWithLocation): Promise<void> {
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<void> {
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<RuleIdentifier> {
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<RuleIdentifier> {
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<void> =>
(
{
values,
existing,
redirectOnSave,
}: {
values: RuleFormValues;
existing?: RuleWithLocation;
redirectOnSave?: string;
},
thunkAPI
): Promise<void> =>
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<boolean, string>(
'unifiedalerting/checkIfLotexRuleEditingSupported',
async (rulesSourceName: string): Promise<boolean> =>
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<void> => {
@ -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);
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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) {

View File

@ -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', () => {

View File

@ -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<RuleWithLocation | null>;
deleteRule(ruleWithLocation: RuleWithLocation): Promise<void>;
saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier>;
saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier>;
}
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<RuleWithLocation | null> => {
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<void> => {
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<RuleIdentifier> => {
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<RuleIdentifier> => {
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,
};
}

View File

@ -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<PromOptions>,
@ -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<JSX.Element | null> {
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 = <Badge color="green" icon="check" text="Ruler API enabled" />;
const disabled = <Badge color="orange" icon="exclamation-triangle" text="Ruler API not enabled" />;
const unsupported = (
<Tooltip
placement="top"
content="Prometheus does not allow editing rules, connect to either a Mimir or Cortex datasource to manage alerts via Grafana."
>
<div>
<Badge color="red" icon="exclamation-triangle" text="Ruler API not supported" />
</div>
</Tooltip>
);
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, BadgeColor> = {
[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 = (
<Badge
text={
<span>
<img
style={{ width: 14, height: 14, verticalAlign: 'text-bottom' }}
src={LOGOS[buildInfo.application ?? PromApplication.Prometheus]}
/>{' '}
{buildInfo.application}
</span>
}
color={COLORS[buildInfo.application ?? PromApplication.Prometheus]}
/>
);
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'max-content max-content',
rowGap: '0.5rem',
columnGap: '2rem',
marginTop: '1rem',
}}
>
<div>Type</div>
<div>{applicationSubType}</div>
<>
<div>Ruler API</div>
{/* Prometheus does not have a Ruler API so show that it is not supported */}
{buildInfo.application === PromApplication.Prometheus && <div>{unsupported}</div>}
{buildInfo.application !== PromApplication.Prometheus && (
<div>{buildInfo.features.rulerApiEnabled ? enabled : disabled}</div>
)}
</>
</div>
);
}
async testDatasource() {
const now = new Date().getTime();
const request: DataQueryRequest<PromQuery> = {
@ -828,12 +917,20 @@ export class PrometheusDatasource
},
} as DataQueryRequest<PromQuery>;
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) => {

View File

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g fill="#3b697e"><path d="m64.001 32c0-17.646-14.356-32.001-32.001-32.001-17.646 0-32.001 14.355-32.001 32.001s14.355 32.001 32.001 32.001 32.001-14.355 32.001-32.001zm-62.592 0c0-16.868 13.723-30.591 30.591-30.591s30.591 13.723 30.591 30.591-13.723 30.591-30.591 30.591-30.591-13.723-30.591-30.591z" fill-rule="nonzero"/><circle cx="49.3" cy="32.725" r="5.296"/><circle cx="31.875" cy="52.94" r="5.296"/><path d="m31.148 33.103 3.295-8.185 2.988 8.538h2.684c.226 2.71 1.641 5.186 3.861 6.757l-8.659 8.659.665.665 8.808-8.808c1.378.774 2.932 1.18 4.512 1.18 5.056 0 9.216-4.16 9.216-9.215 0-5.056-4.16-9.216-9.216-9.216-1.464 0-2.907.349-4.209 1.017l-9.039-8.587c.797-.953 1.234-2.156 1.234-3.399 0-2.906-2.391-5.297-5.297-5.297s-5.297 2.391-5.297 5.297 2.391 5.297 5.297 5.297c1.242 0 2.445-.436 3.397-1.233l8.86 8.417c-2.542 1.666-4.102 4.486-4.162 7.525h-1.987l-3.594-10.267-3.992 9.915h-4.457l-3.788 8.766-3.341-8.353-.01.004v-.09h-3.378c-.124-2.816-2.474-5.064-5.292-5.064-2.907 0-5.298 2.391-5.298 5.298 0 2.906 2.391 5.298 5.298 5.298 2.64 0 4.898-1.975 5.25-4.592h2.759l3.978 9.947 4.44-10.274zm18.152-8.685c4.54 0 8.276 3.736 8.276 8.276s-3.736 8.276-8.276 8.276-8.276-3.736-8.276-8.276c.005-4.538 3.737-8.271 8.276-8.276z" fill-rule="nonzero"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(0 14.3827 -14.3827 0 7.68261 2.43042)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#f2c144"/><stop offset=".24" stop-color="#f1a03b"/><stop offset=".57" stop-color="#f17a31"/><stop offset=".84" stop-color="#f0632a"/><stop offset="1" stop-color="#f05a28"/></linearGradient><path d="m1.941 13.102h2.194l1.538-2.953-1.065-2.043zm11.935-5.079-1.2 2.303 1.406 2.742 1.2-2.313zm-.364-.705-2.562-4.971-1.263 2.228 2.623 5.049zm-3.991 3.039 1.429 2.743h2.436l-2.648-5.083zm-5.276-2.952-1.195-2.305-1.525 2.933 1.172 2.269zm-3.077 1.327-1.09 2.152 1.2 2.179 1.063-2.057zm8.113-3.494-1.27 2.243 1.146 2.179 1.219-2.34zm-4.792-2.98-1.096 2.133 4.264 8.154 1.137-2.182-2.155-4.135z" fill="url(#a)" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@ -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": [

View File

@ -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;

View File

@ -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;
}