mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
4c6d2ce618
commit
6992d17924
@ -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 {
|
||||
|
130
pkg/services/ngalert/api/lotex_ruler_test.go
Normal file
130
pkg/services/ngalert/api/lotex_ruler_test.go
Normal 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
|
||||
}
|
@ -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,
|
||||
|
@ -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());
|
||||
|
@ -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 />;
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
123
public/app/features/alerting/unified/api/buildInfo.test.ts
Normal file
123
public/app/features/alerting/unified/api/buildInfo.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
130
public/app/features/alerting/unified/api/buildInfo.ts
Normal file
130
public/app/features/alerting/unified/api/buildInfo.ts
Normal 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
|
||||
);
|
||||
}
|
@ -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) {
|
||||
|
104
public/app/features/alerting/unified/api/ruler.test.ts
Normal file
104
public/app/features/alerting/unified/api/ruler.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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', () => {
|
229
public/app/features/alerting/unified/utils/rulerClient.ts
Normal file
229
public/app/features/alerting/unified/utils/rulerClient.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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) => {
|
@ -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 |
@ -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 |
@ -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": [
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user